diff --git a/CMakeLists.txt b/CMakeLists.txt index 0bdcc1a5..6d396f7c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -459,6 +459,11 @@ if(DINGOSDK_BUILD_PLUGIN_TESTS) add_test(NAME plugins COMMAND dingosdk_plugin_tests) endif() if(DINGOSDK_BUILD_PLUGIN_TESTS AND WIN32) + add_executable(dingosdk_effect_geometry_tests Extension/Rendering/Test/effect_geometry_tests.cpp + Extension/Skater/skater_physics.cpp Extension/Plugins/plugin_effects.cpp) + target_include_directories(dingosdk_effect_geometry_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_effect_geometry_tests PRIVATE dingosdk_logging) + add_test(NAME effect_geometry COMMAND dingosdk_effect_geometry_tests) # Tricks read from the score HUD's values (Extension/Skater/trick_watch.h), what plugins hear. add_executable(dingosdk_trick_watch_tests Extension/Skater/Test/trick_watch_tests.cpp Extension/Skater/trick_watch.cpp) target_include_directories(dingosdk_trick_watch_tests PRIVATE "${PROJECT_SOURCE_DIR}") diff --git a/Engine/Game/Build/20260929/decals.h b/Engine/Game/Build/20260929/decals.h new file mode 100644 index 00000000..8ed3cbda --- /dev/null +++ b/Engine/Game/Build/20260929/decals.h @@ -0,0 +1,66 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" +#include +namespace dingosdk::game::build::v20260929::decals { +// Steam build 25414733; executable SHA256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. +// Native EnvironmentDecalVolume factory 0x199f740 and service constructor 0x198f450. +inline constexpr std::uintptr_t manager=0x749b1d8, manager_vtable=0x6289f70; +inline constexpr std::uintptr_t manager_realm = 0x70, realm_update_queue = 0x400; +inline constexpr std::uintptr_t create=0x1987220; +inline constexpr std::uintptr_t release=0x19862f0; +inline constexpr std::uintptr_t properties_init=0x5b09bb0; +inline constexpr std::uintptr_t settings_init=0x5b09c70; +inline constexpr std::uintptr_t reference_assign=0x3dd1d70; +inline constexpr std::uintptr_t reference_release=0x3dc5e90; +inline constexpr std::uintptr_t manager_create=0x1991bf0; +inline constexpr std::uintptr_t manager_release=0x19925c0; +inline constexpr std::uintptr_t resource_release=0x48a9f80; +inline constexpr std::uintptr_t current_context=0x18b6ae0; +// Service vtable +0x20: (manager, owned decal id, Properties*). This merges +// flagged fields into the realm's queued properties; it is not the +0x30 +// time setter. Properties +0x60 is opacity, selected by flag 4 at +0x70. +inline constexpr std::uintptr_t update_properties=0x19939a0; +// Service vtable +0x40 forwards the owned decal's resource and a 64-byte +// world matrix to resource transform setter 0x48c2e50. +inline constexpr std::uintptr_t update_transform=0x1993590; +// Properties merge 0x5b0a460 selects receiver mask +0x6c with flag 0x2000. +inline constexpr std::array surface_contracts{{ + {0x1993590, {0x48,0x89,0x5c,0x24,0x08,0x48,0x89,0x74,0x24,0x10,0x57,0x48,0x83,0xec,0x20,0x4c,0x8d,0x91,0xc8,0x00,0x00,0x00,0x48,0xbb,0xff,0xff,0xff,0xff,0xff,0xff,0xff,0x7f}}, + {0x48c2e50, {0x4c,0x8b,0xc9,0x4c,0x8b,0xc2,0x48,0x8b,0x0d,0x9b,0x12,0xef,0x02,0x41,0x8b,0x11,0x48,0x8b,0x01,0x48,0xff,0x60,0x38,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc}}, + {0x5b0a607, {0xba,0x00,0x20,0x00,0x00,0x66,0x85,0x16,0x48,0x8b,0x74,0x24,0x48,0x74,0x0d,0x8b,0x45,0x6c,0x66,0x0b,0x11,0x66,0x41,0x89,0x10,0x89,0x47,0x6c,0x48,0x83,0xc4,0x28}}, +}}; +inline constexpr std::array opacity_contracts{{ + {0x19939a0, {0x40,0x53,0x56,0x41,0x57,0x48,0x83,0xec,0x40,0x44,0x8b,0xfa,0x48,0x8b,0xd9,0x48,0x8d,0x54,0x24,0x78,0x49,0x8b,0xc8,0x49,0x8b,0xf0,0xe8,0x21,0x71,0x17,0x04,0x66}}, + {0x5b0a220, {0x48,0x89,0x54,0x24,0x10,0x53,0x55,0x56,0x57,0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x38,0x41,0x0f,0xb6,0x40,0x72,0x45,0x33,0xdb,0xc4,0xc1,0x7a}}, + {0x5b0a460, {0x40,0x55,0x57,0x48,0x83,0xec,0x28,0x41,0xf6,0x00,0x01,0x48,0x8b,0xea,0x48,0x89,0x74,0x24,0x48,0x48,0x8b,0xf9,0x49,0x8b,0xf0,0x74,0x0c,0x0f,0xb6,0x42,0x72,0x66}}, +}}; +inline constexpr std::array contracts{{ + {0x1987220, {0x48,0x89,0x5c,0x24,0x10,0x48,0x89,0x6c,0x24,0x18,0x57,0x48,0x83,0xec,0x50,0x41,0x0f,0xb6,0x40,0x10,0x48,0x8b,0xda,0xc4,0xc1,0x78,0x10,0x00,0x88,0x44,0x24,0x40}}, + {0x19862f0, {0x8b,0x11,0x45,0x33,0xc0,0x44,0x38,0x41,0x04,0x41,0x0f,0x94,0xc0,0x33,0xc0,0x85,0xd2,0x0f,0x95,0xc0,0x44,0x85,0xc0,0x74,0x0e,0x48,0x8b,0x0d,0xc8,0x4e,0xb1,0x05}}, + {0x5b09bb0, {0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0xd9,0xc7,0x01,0x00,0x00,0x80,0x3f,0xc7,0x41,0x04,0x00,0x00,0x80,0x3f,0x48,0xc7,0x41,0x08,0x00,0x00,0x80,0x3f,0x48,0x83}}, + {0x5b09c70, {0x40,0x53,0x48,0x83,0xec,0x30,0xc5,0xfa,0x10,0x1d,0x76,0x22,0x5e,0x00,0xc5,0xfa,0x10,0x15,0x5e,0x22,0x5e,0x00,0xc5,0xfa,0x10,0x0d,0x42,0x22,0x5e,0x00,0xc5,0xfa}}, + {0x3dd1d70, {0x4c,0x8b,0x01,0x4c,0x8b,0xc9,0x49,0x8b,0xc8,0x48,0x8b,0xc2,0x48,0x83,0xe1,0xfb,0x48,0x83,0xe0,0xfb,0x48,0x3b,0xc8,0x74,0x31,0xb8,0x00,0x20,0x00,0x00,0x48,0x85}}, + {0x3dc5e90, {0x48,0x8b,0x01,0x48,0x85,0xc0,0x74,0x1c,0x48,0x8b,0xc8,0x48,0x83,0xe1,0xfb,0x74,0x13,0xa8,0x04,0x75,0x0f,0xb8,0x00,0x20,0x00,0x00,0x66,0x85,0x41,0x14,0x0f,0x84}}, + {0x1991bf0, {0x40,0x53,0x55,0x56,0x57,0x41,0x56,0x41,0x57,0x48,0x83,0xec,0x28,0x48,0x8d,0x69,0x10,0x45,0x0f,0xb6,0xf9,0x48,0x8d,0x8d,0xb8,0x00,0x00,0x00,0x49,0x8b,0xf0,0x48}}, + {0x19925c0, {0x40,0x56,0x57,0x41,0x57,0x48,0x83,0xec,0x20,0x48,0x8d,0x71,0x10,0x44,0x8d,0x7a,0xfe,0x41,0x8b,0xd7,0x48,0x8d,0x8e,0xb8,0x00,0x00,0x00,0xe8,0xc0,0x8c,0xeb,0xff}}, + {0x48a9f80, {0x8b,0x11,0x45,0x33,0xc0,0x44,0x38,0x41,0x04,0x41,0x0f,0x94,0xc0,0x33,0xc0,0x85,0xd2,0x0f,0x95,0xc0,0x44,0x85,0xc0,0x74,0x0e,0x48,0x8b,0x0d,0x58,0xa1,0xf0,0x02}}, + {0x18b6ae0, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x65,0x48,0x8b,0x04,0x25,0x58,0x00,0x00,0x00,0x48,0x8b,0xd9,0x8b,0x15,0xe4,0xd5,0x01,0x06,0x48,0x8b,0x3c,0xd0}}, + {0x1986260, {0x48,0x89,0x74,0x24,0x18,0x41,0x56,0x48,0x83,0xec,0x20,0x48,0x8b,0x31,0x4c,0x8b,0xf1,0xe8,0x8a,0x58,0x44,0x02,0x48,0x3b,0xf0,0x74,0x64,0x49,0x8b,0x16,0x48,0x89}}, + {0xc89ce0, {0x48,0x89,0x74,0x24,0x18,0x41,0x56,0x48,0x83,0xec,0x20,0x48,0x8b,0x31,0x4c,0x8b,0xf1,0xe8,0x0a,0x1e,0x14,0x03,0x48,0x3b,0xf0,0x74,0x70,0x49,0x8b,0x06,0x48,0x89}}, + {0x1586050, {0x48,0x8b,0x09,0x48,0x8d,0x05,0xaa,0x86,0xd7,0x05,0x48,0x3b,0xc8,0x0f,0x85,0xbd,0x9b,0xff,0xff,0xc3,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc}}, + {0x1355e50, {0x48,0x89,0x74,0x24,0x18,0x41,0x56,0x48,0x83,0xec,0x20,0x48,0x8b,0x31,0x4c,0x8b,0xf1,0xe8,0x9a,0x5c,0xa7,0x02,0x48,0x3b,0xf0,0x74,0x68,0x49,0x8b,0x16,0x48,0x89}}, + {0xadd970, {0x48,0x89,0x74,0x24,0x18,0x41,0x56,0x48,0x83,0xec,0x20,0x48,0x8b,0x31,0x4c,0x8b,0xf1,0xe8,0x7a,0xe1,0x2e,0x03,0x48,0x3b,0xf0,0x74,0x68,0x49,0x8b,0x06,0x48,0x89}}, + {0x2aab10, {0x48,0x89,0x74,0x24,0x18,0x41,0x56,0x48,0x83,0xec,0x20,0x48,0x8b,0x31,0x4c,0x8b,0xf1,0xe8,0xda,0x0f,0xb2,0x03,0x48,0x3b,0xf0,0x74,0x67,0x49,0x8b,0x06,0x48,0x89}}, +}}; +inline constexpr std::array,9> property_cleanup{{ + {0x58,0x3dc5e90}, + {0x50,0x1986260}, + {0x40,0xc89ce0}, + {0x38,0x1586050}, + {0x30,0xc89ce0}, + {0x28,0x3dc5e90}, + {0x20,0x1355e50}, + {0x18,0xadd970}, + {0x10,0x2aab10}, +}}; +} diff --git a/Engine/Game/Build/20260929/effect_surface.h b/Engine/Game/Build/20260929/effect_surface.h new file mode 100644 index 00000000..0ebb95b2 --- /dev/null +++ b/Engine/Game/Build/20260929/effect_surface.h @@ -0,0 +1,16 @@ +#pragma once +#include "park_editor.h" + +namespace dingosdk::game::build::v20260929::effect_surface { +// Same executable as decals.h. Paired position/quaternion readers are used +// by 0x1a4ae60 and 0x3d04c80. Input: 16-byte physics world/index/generation +// handle. Output: aligned float4, quaternion in XYZW order. +inline constexpr std::uintptr_t body_position=0x25d6e60,body_rotation=0x25d5db0; +inline constexpr std::uintptr_t scope_pop_slot = 0x8, result_fractions = 0x8; +inline constexpr std::array contracts{{ + park_editor::picking_contracts[0], // ray hit -> generation-bearing body handle + park_editor::picking_contracts[1], // allocated bit and generation validation + {0x25d6e60, {0x40,0x53,0x48,0x83,0xec,0x50,0x48,0x8b,0x01,0x48,0x8b,0xda,0x44,0x8b,0x49,0x08,0x45,0x8b,0xc1,0x41,0x0f,0xb6,0xc9,0x49,0xc1,0xe8,0x03,0x80,0xe1,0x07,0x4c,0x8b}}, + {0x25d5db0, {0x40,0x53,0x48,0x83,0xec,0x50,0x48,0x8b,0x01,0x48,0x8b,0xda,0x44,0x8b,0x49,0x08,0x45,0x8b,0xc1,0x41,0x0f,0xb6,0xc9,0x49,0xc1,0xe8,0x03,0x80,0xe1,0x07,0x4c,0x8b}}, +}}; +} diff --git a/Engine/Game/Build/20260929/effects.h b/Engine/Game/Build/20260929/effects.h new file mode 100644 index 00000000..2705644b --- /dev/null +++ b/Engine/Game/Build/20260929/effects.h @@ -0,0 +1,26 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" + +namespace dingosdk::game::build::v20260929::effects { +// Supported Skate.exe SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. +// EffectEntity::onStart (29d0b40) and the skater VFX factory (486e720) +// construct the same 0x100-byte descriptor and own an 8-byte effect reference. +inline constexpr std::uintptr_t service=0x74deb20; +// Asset+8 is its TypeInfo; *TypeInfo is the record whose first word is the +// generated type identity, not native_name_hash(class name). Confirmed on the +// loaded private assets and in records 0x80c7870 / 0x8090360 of the pinned image. +inline constexpr std::uintptr_t client_world = 0x8, asset_type = 0x8; +inline constexpr std::uint32_t particle_type_hash = 0xf258c6f9; // EffectBlueprint +inline constexpr std::uint32_t decal_type_hash = 0x5de4ae3c; // EnvironmentDecalVolumeTemplateData +inline constexpr std::uint16_t domain_count = 0xbbf; +inline constexpr Fingerprint descriptor_init{0x2990dd0,{0xc4,0xc1,0x7c,0x10,0x00,0xc5,0xfc,0x11,0x01,0xc4,0xc1,0x7c,0x10,0x48,0x20,0x45,0x33,0xc0,0x44,0x88,0x41,0x48,0xc5,0xfc,0x11,0x49,0x20,0x44,0x89,0x41,0x40,0x44}}; +inline constexpr Fingerprint descriptor_destroy{0x2991490,{0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0xd9,0x48,0x81,0xc1,0xe0,0x00,0x00,0x00,0x48,0x8b,0x41,0x10,0x48,0x85,0xc0,0x74,0x07,0x45,0x33,0xc0,0x33,0xd2,0xff,0xd0}}; +inline constexpr Fingerprint create{0x2996b70,{0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0x0d,0xa3,0x7f,0xb4,0x04,0x48,0x8b,0xda,0x49,0x8b,0xd0,0x48,0x8b,0x01,0xff,0x50,0x10,0x48,0x89,0x03,0x48,0x8b,0xc3,0x48}}; +// EffectEntity's 29cef80/29cd4e0 callers pass a 64-byte world matrix. +inline constexpr Fingerprint move{0x299a180,{0x4c,0x8b,0xc2,0x48,0x8b,0x11,0x48,0x85,0xd2,0x74,0x0e,0x48,0x8b,0x0d,0x8e,0x49,0xb4,0x04,0x48,0x8b,0x01,0x48,0xff,0x60,0x30,0xc3,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc}}; +inline constexpr Fingerprint stop{0x299b5e0,{0x44,0x0f,0xb6,0xc2,0x48,0x8b,0x11,0x48,0x85,0xd2,0x74,0x12,0x48,0x8b,0x0d,0x2d,0x35,0xb4,0x04,0xc5,0xf8,0x28,0xda,0x48,0x8b,0x01,0x48,0xff,0x60,0x28,0xc3,0xcc}}; +inline constexpr Fingerprint release{0x29915a0,{0x40,0x53,0x48,0x83,0xec,0x20,0x48,0x8b,0x11,0x48,0x8b,0xd9,0x48,0x85,0xd2,0x74,0x14,0x48,0x8b,0x0d,0x68,0xd5,0xb4,0x04,0x48,0x8b,0x01,0xff,0x50,0x20,0x48,0xc7}}; +inline constexpr std::size_t descriptor_size = 0x100, descriptor_flags = 0xc0; +inline constexpr std::uint32_t descriptor_flags_value = 0x20; +inline constexpr std::array service_methods{0x10, 0x20, 0x28, 0x30}; +} diff --git a/Engine/Game/Build/20260929/replay_activity.h b/Engine/Game/Build/20260929/replay_activity.h new file mode 100644 index 00000000..ce341a2d --- /dev/null +++ b/Engine/Game/Build/20260929/replay_activity.h @@ -0,0 +1,15 @@ +#pragma once +#include "Engine/Game/Build/fingerprint.h" + +namespace dingosdk::game::build::v20260929::replay_activity { +// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9. +// Client ctor 50c1f0 publishes the singleton and creates the session manager +// at +568 with backend +50 (vtable 6084518). 6240e0/6241e0 install a lease +// at manager+8; the lease's +10 points back to that manager. +inline constexpr std::uintptr_t client = 0x71eaba8, manager = 0x568; +inline constexpr std::uintptr_t backend = 0x50, backend_vtable = 0x6084518; +inline constexpr std::uintptr_t lease = 8, lease_manager = 0x10, lease_vtable = 0x60ca700; +// Backend +88 -> 6123b0 -> 41a6960 reads the playhead at 718ffd0 when +// 7726468 is nonzero. Backend +90 -> 6254b0 -> 41a9f40 writes this time. +inline constexpr std::uintptr_t playing = 0x7726468, playhead = 0x718ffd0; +} diff --git a/Engine/Game/Build/20260929/skater_body.h b/Engine/Game/Build/20260929/skater_body.h index c7da2f56..28f31592 100644 --- a/Engine/Game/Build/20260929/skater_body.h +++ b/Engine/Game/Build/20260929/skater_body.h @@ -80,6 +80,12 @@ 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_normal_offset = 0x00, bone_point_offset = 0x40; +// The physics getter copies body n's world matrix from +0x20, 64 bytes (47c8590). +inline constexpr Fingerprint body_transform_contract{0x47c8590, { + 0x49,0x63,0xc0,0x4c,0x69,0xc0,0x30,0x01,0x00,0x00,0x48,0x8b,0x41,0x20,0xc4,0xc1, + 0x7c,0x10,0x44,0x00,0x20,0xc4,0xc1,0x7c,0x10,0x4c,0x00,0x40,0xc5,0xfc,0x11,0x02}}; +inline constexpr std::uintptr_t body_transform_offset = 0x20; 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 diff --git a/Engine/Game/Settings/transient_count_lease.h b/Engine/Game/Settings/transient_count_lease.h new file mode 100644 index 00000000..3963cd81 --- /dev/null +++ b/Engine/Game/Settings/transient_count_lease.h @@ -0,0 +1,47 @@ +#pragma once +#include +#include + +namespace dingosdk { +struct CountSettingSample { + std::uintptr_t manager{},address{},type{}; + std::uint32_t value{}; + bool operator==(const CountSettingSample&) const = default; +}; +// Adds a bounded temporary allowance to a renderer count. External edits and +// replaced settings objects relinquish ownership until the effect is cleared. +class TransientCountLease { +public: + template bool update(std::uint32_t extra,Read&& read,Write&& write) { + if (!extra) return restore(read,write); + if (extra>4096 || yielded_) return false; + const auto current=read(); + if (!current || current->value>65536-extra) return false; + if (applied_ && *current!=*applied_) {original_.reset(); applied_.reset(); yielded_=true; return false;} + if (!original_) original_=current; + return apply(original_->value+extra,*current,read,write); + } + template bool restore(Read&& read,Write&& write) { + if (!original_) {yielded_=false; return true;} + const auto current=read(); + if (!current) return false; + if (applied_ && *current==*applied_ && !apply(original_->value,*current,read,write)) return false; + original_.reset(); applied_.reset(); yielded_=false; + return true; + } +private: + template bool apply(std::uint32_t value,const CountSettingSample& current,Read&& read,Write&& write) { + applied_=current; + if (value==current.value) return true; + const bool accepted=write(current,value); + const auto after=read(); + if (!after) {applied_->value=value; return false;} + auto expected=current; expected.value=value; + if (*after==expected) {applied_=after; return accepted;} + if (*after!=current) {original_.reset(); applied_.reset(); yielded_=true;} + return false; + } + std::optional original_,applied_; + bool yielded_{}; +}; +} diff --git a/Engine/Game/Skater/skater_body.h b/Engine/Game/Skater/skater_body.h index 720e9188..aa4c9f0c 100644 --- a/Engine/Game/Skater/skater_body.h +++ b/Engine/Game/Skater/skater_body.h @@ -3,6 +3,7 @@ #include #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; @@ -48,6 +49,7 @@ struct BodyContact { 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; + std::optional point, normal; // hardest contact, world space; absent if unreadable }; // The whole body in one physics step. struct Contacts { diff --git a/Extension/Decals/native_decals.h b/Extension/Decals/native_decals.h new file mode 100644 index 00000000..4dc8c422 --- /dev/null +++ b/Extension/Decals/native_decals.h @@ -0,0 +1,131 @@ +#pragma once +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/20260929/decals.h" +#include "Engine/Game/Build/addresses.h" +#include +#include +#include + +namespace dingosdk::decals::native { +namespace build = addr::decals; +struct Handle { std::uint32_t id{}; bool borrowed{}; unsigned char padding[3]{}; }; +static_assert(sizeof(Handle)==8); +struct Settings { + std::array culling{}; + std::uint8_t priority{}, flags{}; + std::uint16_t padding{}; + Handle resource; +}; +static_assert(sizeof(Settings)==0x1c && offsetof(Settings,resource)==0x14); +struct alignas(16) Properties { + std::array params{}; + std::array shader{}; + std::uint64_t shader_key{}, blocks{}, material{}; + float alpha{}, override_culling{}; + std::uint32_t shader_id{}, mask{}; + std::uint16_t flags{}; + bool enabled{}, detached{}; + std::uint8_t row{}, column{}, default_expression{}; + std::array padding{}; +}; +static_assert(sizeof(Properties)==0x80 && offsetof(Properties,material)==0x58 && + offsetof(Properties,alpha)==0x60 && offsetof(Properties,mask)==0x6c && offsetof(Properties,flags)==0x70); +enum class ReceiverMask : std::uint32_t { all=0,static_only=1,dynamic_only=2,terrain_only=3 }; +inline void assign_mask(Properties& props,ReceiverMask mask) { + props.mask=static_cast(mask); props.flags|=0x2000; +} + +// 0x3dd1d70 is the raw-object assignment overload, not reference-to-reference +// copy: RDX is stored directly in [RCX] and its refcount at RDX+0x10 retained. +// Passing &asset instead retains stack memory and crashes the render worker. +using ReferenceAssign = void(*)(std::uint64_t*,std::uintptr_t); +inline void assign_material(Properties& props,std::uintptr_t asset,ReferenceAssign assign) { + assign(&props.material,asset); + props.flags|=0x40; +} +template F function(std::uintptr_t base, std::uintptr_t rva) {return reinterpret_cast(base+rva);} +inline void destroy_properties(std::uintptr_t base,Properties& props) { + auto* bytes=reinterpret_cast(&props); + for (const auto& item:build::property_cleanup) + function(base,item.second)(bytes+item.first); +} +inline bool opacity_contracts_match(std::uintptr_t base) { + return std::all_of(build::opacity_contracts.begin(),build::opacity_contracts.end(),[&](const auto& c) { + std::array bytes{}; + return memory::read_bytes(base+c.rva,bytes.data(),bytes.size()) && bytes==c.bytes; + }); +} +inline void set_opacity(std::uintptr_t base,std::uintptr_t manager,std::uint32_t id,float alpha) { + if (!id) return; + // The native comparison reads descriptor arrays even for a scalar update: + // use the real initializer and destructor, never a zeroed partial struct. + Properties props{}; + function(base,build::properties_init)(&props); + struct Cleanup { + std::uintptr_t base; Properties& props; + ~Cleanup() {destroy_properties(base,props);} + } cleanup{base,props}; + props.alpha=std::clamp(alpha,0.f,1.f); props.flags=4; + function(base,build::update_properties)(manager,id,&props); +} +inline bool contracts_match(std::uintptr_t base) { + return std::all_of(build::contracts.begin(),build::contracts.end(),[&](const auto& c) { + std::array bytes{}; + return memory::read_bytes(base+c.rva,bytes.data(),bytes.size()) && bytes==c.bytes; + }); +} +inline bool surface_contracts_match(std::uintptr_t base) { + return std::all_of(build::surface_contracts.begin(),build::surface_contracts.end(),[&](const auto& c) { + std::array bytes{}; + return memory::read_bytes(base+c.rva,bytes.data(),bytes.size()) && bytes==c.bytes; + }); +} +inline void set_transform(std::uintptr_t base,std::uintptr_t manager,std::uint32_t id,const std::array& transform) { + if (!id) return; + alignas(16) const auto matrix=transform; + function(base,build::update_transform)(manager,id,matrix.data()); +} +inline std::uintptr_t manager(std::uintptr_t base) { + std::uintptr_t result{}, table{}; + if (!memory::read(base+build::manager,result) || !result || !memory::read(result,table) || + table!=base+build::manager_vtable) return 0; // Exclude the engine's null service. + return result; +} +inline Handle create(std::uintptr_t base, std::uintptr_t manager, std::uintptr_t material, + const std::array& transform,std::optional mask=std::nullopt) { + // The concrete allocator (0x1993620) dereferences the client realm's + // state at manager+0x70 and its pending-update queue at state+0x400. + // The service can exist before that realm is attached. + std::uintptr_t realm{}, queue{}; + if (!memory::peek(manager+build::manager_realm,realm) || !realm || !memory::peek(realm+build::realm_update_queue,queue) || !queue) + return {}; + alignas(16) Settings settings{}; + function(base,build::settings_init)(&settings); + settings.culling={100,100,100,100}; settings.flags|=3; + Properties props{}; + function(base,build::properties_init)(&props); + struct Cleanup { + std::uintptr_t base; Properties& p; Settings& s; + ~Cleanup() { + // Exact stack-descriptor cleanup from EnvironmentDecalVolume's + // factory (0x199f740), in reverse construction order. + destroy_properties(base,p); + function(base,build::resource_release)(&s.resource); + } + } cleanup{base,props,settings}; + assign_material(props,material,function(base,build::reference_assign)); + if (mask) assign_mask(props,*mask); + props.flags|=1; props.enabled=true; + // A standalone resource has no parent. Native create copies both stack + // descriptors and gives the returned handle one owned reference. + Handle result{}, parent{}; + alignas(16) const auto matrix=transform; + function(base,build::create)(manager,&result,&settings,&props,&parent,matrix.data(),false); + return result; +} +inline void release(std::uintptr_t base, Handle& handle) { + if (handle.id) function(base,build::release)(&handle); + handle={}; +} +} diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index be2901b2..aee425f8 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -30,6 +30,30 @@ std::filesystem::path scratch; // The wider set's game, faked: it records what plugins asked of it and answers as told. struct FakeGame final : Game { + struct Effect { std::string owner; EffectRequest request; bool stopped{}; float alpha{1}; }; + std::map effects; + std::uint64_t effect_serial{}, effect_epoch{1}; + bool effects_ready{true}; + std::uint64_t effect_create(std::string_view plugin, const EffectRequest& request) override { + if (!effects_ready) return 0; + const auto id = ++effect_serial; + effects.emplace(id, Effect{std::string(plugin), request}); + return id; + } + void effect_move(std::string_view plugin, std::uint64_t id, const EffectMatrix& matrix) override { + if (auto it = effects.find(id); it != effects.end() && it->second.owner == plugin) it->second.request.transform = matrix; + } + void effect_stop(std::string_view plugin, std::uint64_t id) override { + if (auto it = effects.find(id); it != effects.end() && it->second.owner == plugin) it->second.stopped = true; + } + void effect_opacity(std::string_view plugin, std::uint64_t id, float value) override { + if (auto it = effects.find(id); it != effects.end() && it->second.owner == plugin) it->second.alpha = value; + } + void effect_release(std::string_view plugin, std::uint64_t id) override { + if (auto it = effects.find(id); it != effects.end() && it->second.owner == plugin) effects.erase(it); + } + void effects_clear(std::string_view plugin) override { std::erase_if(effects, [&](const auto& e) { return e.second.owner == plugin; }); } + std::pair effects_state() override { return {effects_ready, effect_epoch}; } std::optional state; std::uint64_t wipe{}; std::vector calls; @@ -622,7 +646,7 @@ struct FakeGame final : Game { return {}; } std::vector> take_presses() override { return std::exchange(presses, {}); } - void release(std::string_view plugin) override { released.emplace_back(plugin); entity_clear(plugin); } + void release(std::string_view plugin) override { released.emplace_back(plugin); entity_clear(plugin); effects_clear(plugin); } // The game menu, its item grid, what is worn, and a screen capture of something turning once // every `turning` frames (reskate.capture, room.turn, menu.game/grid, outfit.board). GameMenu menu_now; @@ -835,6 +859,207 @@ void stopped(const Host& host, std::string_view id, std::string_view because) { check(status.reason.find(because) != std::string::npos, std::string(id) + " stopped for \"" + status.reason + "\", not for " + std::string(because)); } +void plugins_render_private_effects() { + TestEnvironment environment; + // Filling every decal slot tests the resource quota, with room for a busy CI machine. + Limits limits; + limits.call_ms = 1000; + Host host(environment, Side::client, limits); + host.apply({script("effects-denied", R"( + for _,f in pairs(reskate.effects) do + local ok,why=pcall(f) + assert(not ok and why:find("permission"), tostring(why)) + end + reskate.log("all effects denied") + )")}); + running(host, "effects-denied", environment); + check(environment.logged("all effects denied"), environment.all()); + check(environment.fake.effects.empty(), "A plugin without permission cannot reach the renderer"); + auto declaration = dingosdk::Json::parse(manifest(R"("effects", "console")")); + const std::string particle = "effects/reskate/test/ebp_spark"; + const std::string material = "world/materials/decal/reskate_test/stain_dv"; + declaration["effects"] = dingosdk::Json::array({particle, material}); + for (unsigned scenario = 0; scenario < 3; ++scenario) { + auto invalid = declaration; + if (scenario == 0) invalid["permissions"] = dingosdk::Json::array(); + else if (scenario == 1) invalid["effects"] = particle; + else { + auto names = dingosdk::Json::array(); + for (unsigned i = 0; i < 129; ++i) names.push_back(particle + std::to_string(i)); + invalid["effects"] = std::move(names); + } + bool refused = false; + try { (void)parse_manifest(invalid.dump()); } catch (const std::exception&) { refused = true; } + check(refused, "An effect allowlist needs permission, an array and at most 128 names"); + } + for (const auto* forbidden : {"effects/gestures/effectblueprints/ebp_gesture_applause", "effects/reskate/../store/item", "world/materials/decal/paid"}) { + auto invalid = declaration; + invalid["effects"] = dingosdk::Json::array({forbidden}); + bool refused = false; + try { (void)parse_manifest(invalid.dump()); } catch (const std::exception&) { refused = true; } + check(refused, "Stock, store and path escapes are refused in effects manifests"); + } + const auto main = R"( + local fx=reskate.effects + local m={1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1} + local p="effects/reskate/test/ebp_spark" + local d="world/materials/decal/reskate_test/stain_dv" + assert(not pcall(fx.particle,"effects/reskate/test/not_declared",m)) + assert(not pcall(fx.particle,"effects/gestures/effectblueprints/ebp_gesture_applause",m)) + assert(not pcall(fx.decal,p,m)) + local broken=table.clone(m); broken[1]=0/0 + assert(not pcall(fx.particle,p,broken)) + broken=table.clone(m); broken[4]=1 + assert(not pcall(fx.particle,p,broken)) + broken=table.clone(m); broken[1]=0 + assert(not pcall(fx.particle,p,broken)) + assert(not pcall(fx.decal,d,m,{lift=2})) + for i=1,24 do assert(fx.particle(p,m)) end + assert(fx.particle(p,m)==nil,"particle quota") + for i=1,2048 do assert(fx.decal(d,m,{attach=true,lift=.04})) end + assert(fx.decal(d,m)==nil,"decal quota") + fx.clear() + local id=assert(fx.particle(p,m)) + m[13]=3; fx.move(id,m); fx.stop(id) + assert(not pcall(fx.opacity,id,-1)) + local decal=assert(fx.decal(d,m,{attach=true,lift=.04})); fx.opacity(decal,.5) + reskate.console.command("fxgone","",function() fx.release(id); fx.release(decal) end) + reskate.console.command("fxfail","",function() error("intentional") end) + )"; + host.apply({package("effects-owner", {{manifest_file, declaration.dump()}, {"plugin/main.lua", main}})}); + running(host, "effects-owner", environment); + auto& game = environment.fake; + check(game.effects.size() == 2, "Quota objects are cleared before the two live effects"); + for (const auto& [id, e] : game.effects) { + check(e.request.transform[12] == 3, "Effects move without native addresses"); + check(e.request.decal ? e.alpha == .5f && e.request.attach : e.stopped, "Stop, opacity and attachment reach the game"); + } + const auto owned_id = game.effects.begin()->first; + auto intruder = R"(assert(not pcall(reskate.effects.release, )" + std::to_string(owned_id) + R"()); reskate.log("foreign effect denied"))"; + host.apply({{ "effects-owner", scratch / "mods" / "effects-owner", "1", {} }, + package("effects-intruder", {{manifest_file, declaration.dump()}, {"plugin/main.lua", intruder}})}); + running(host, "effects-intruder", environment); + check(environment.logged("foreign effect denied") && game.effects.size() == 2, "Handles never cross plugin ownership"); + check(host.run_console("fxgone") && game.effects.empty(), "Release disposes both owned references"); + host.reload("effects-owner"); + running(host, "effects-owner", environment); + check(game.effects.size() == 2, "Reload starts from a clean owner"); + for (int i = 0; i < 6; ++i) (void)host.run_console("fxfail"); + stopped(host, "effects-owner", "errors"); + check(game.effects.empty(), "Stopping a failing VM releases its effects"); +} + +void plugins_track_effect_lifetimes() { + TestEnvironment environment; + Limits limits; + limits.call_ms = 1000; // Exercise all 8192 operations independently of the timing tests. + Host host(environment, Side::client, limits); + auto declaration = dingosdk::Json::parse(manifest(R"("effects", "console")")); + declaration["effects"] = dingosdk::Json::array({"effects/reskate/test/ebp_spark"}); + host.apply({package("effect-lifetime", {{manifest_file, declaration.dump()}, {"plugin/main.lua", R"( + local fx = reskate.effects + local asset = "effects/reskate/test/ebp_spark" + local transform = {1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1} + local ready, epoch = fx.state() + assert(ready) + local expired = assert(fx.particle(asset, transform)) + reskate.console.command("fxmissing", "", function() + local available, current = fx.state() + assert(not available and current ~= epoch) + local id, why = fx.particle(asset, transform) + assert(id == nil and why:find("unavailable")) + fx.release(expired) + assert(not pcall(fx.release, expired), "a released handle cannot be reused") + fx.clear(); fx.clear() + reskate.log("expired effects cleared") + end) + reskate.console.command("fxbudget", "", function() + for i = 1, 8192 do fx.state() end + local ok, why = pcall(fx.state) + assert(not ok and why:find("8192")) + reskate.log("effect call limit enforced") + end) + reskate.console.command("fxnew", "", function() + assert(fx.state()) + local id = assert(fx.particle(asset, transform)) + assert(id ~= expired, "a new world never reuses a stale handle") + reskate.log("effect created after reset") + end) + )"}})}); + running(host, "effect-lifetime", environment); + auto& game = environment.fake; + check(game.effects.size() == 1, "An effect belongs to the running plugin"); + game.effects_ready = false; + ++game.effect_epoch; + game.effects.clear(); // Native world teardown precedes the plugin's next callback. + check(host.run_console("fxmissing") && environment.logged("expired effects cleared"), environment.all()); + game.effects_ready = true; + ticks(host, environment, 1); + check(host.run_console("fxbudget") && environment.logged("effect call limit enforced"), environment.all()); + ticks(host, environment, 1); + check(host.run_console("fxnew") && environment.logged("effect created after reset") && game.effects.size() == 1, + "The next tick restores the operation allowance\n" + environment.all()); + host.reload("effect-lifetime"); + running(host, "effect-lifetime", environment); + check(game.effects.size() == 1, "Reload releases the previous VM's native effects"); + host.apply({}); + check(game.effects.empty(), "Removing a plugin releases all its effects"); +} + +void physics_steps_include_optional_geometry() { + TestEnvironment environment; + Host host(environment, Side::client); + host.apply({script("physics-geometry", R"( + local samples = 0 + reskate.on("physics_step", function(step) + samples += 1 + local body = step.bodies["Left forearm"] + if samples == 1 then + assert(step.at == 1000 and math.abs(step.seconds - .05) < .000001) + assert(step.state == 300 and step.wipeout and step.ragdoll and not step.airborne) + assert(body.impact == 12 and body.slide == 5 and body.world and not body.board) + assert(body.point[1] == 2 and body.point[2] == 0 and body.point[3] == 4) + assert(body.normal[1] == 0 and body.normal[2] == 1 and body.normal[3] == 0) + local expected = {1,0,0,0,0,1,0,0,0,0,1,0,2,.25,4,1} + for _, name in {"Pelvis", "Left forearm"} do + local pose = step.poses[name] + assert(#pose.transform == 16 and #pose.velocity == 3) + for i, value in expected do assert(pose.transform[i] == value) end + assert(pose.velocity[1] == 5 and pose.velocity[2] == -10 and pose.velocity[3] == 0) + end + assert(step.bodies.Pelvis == nil, "a body can have a pose without a contact") + reskate.log("contact geometry received") + else + assert(step.state == nil and step.ragdoll == nil) + assert(body.point == nil and body.normal == nil and next(step.poses) == nil) + reskate.log("missing geometry stays absent") + end + end) + )", R"("events.physics")"), script("physics-denied", R"( + local ok, why = pcall(reskate.on, "physics_step", function() error("not permitted") end) + assert(not ok and why:find("events.physics")) + reskate.log("physics permission enforced") + )")}); + running(host, "physics-geometry", environment); + running(host, "physics-denied", environment); + check(environment.logged("physics permission enforced"), environment.all()); + PhysicsStep value; + value.at = environment.now; value.seconds = .05f; value.state = 300; + value.wipeout = true; value.ragdoll = true; + const EffectMatrix matrix{1,0,0,0,0,1,0,0,0,0,1,0,2,.25f,4,1}; + value.poses.emplace("Pelvis", PhysicsStep::Pose{matrix, {5, -10, 0}}); + value.poses.emplace("Left forearm", PhysicsStep::Pose{matrix, {5, -10, 0}}); + value.bodies.push_back({"Left forearm", 12, 5, false, false, true, Vec3{2,0,4}, Vec3{0,1,0}}); + environment.fake.steps.push_back(value); + ticks(host, environment, 1); + check(environment.logged("contact geometry received"), environment.all()); + value.state.reset(); value.ragdoll.reset(); value.poses.clear(); + value.bodies[0].point.reset(); value.bodies[0].normal.reset(); + environment.fake.steps.push_back(value); + ticks(host, environment, 1); + check(environment.logged("missing geometry stays absent"), environment.all()); +} + void a_manifest_says_what_a_plugin_is() { const auto good = parse_manifest(manifest(R"("console", "storage")")); check(good.api == 1 && good.entry == "plugin/main.lua" && good.side == Side::client, "A manifest is read"); @@ -4083,6 +4308,9 @@ int main() { scratch = std::filesystem::temp_directory_path() / "reskate-plugin-tests"; std::filesystem::remove_all(scratch); run(a_manifest_says_what_a_plugin_is); + run(plugins_render_private_effects); + run(plugins_track_effect_lifetimes); + run(physics_steps_include_optional_geometry); run(a_plugin_loads_ticks_runs_its_commands_and_unloads); run(a_permission_must_be_declared); run(command_names_are_checked); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index edbd05d8..30cad6b3 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -746,6 +746,7 @@ void Api::install(Plugin& plugin, lua_State* L) { install_services_api(plugin, L); install_kit_api(plugin, L); install_capture_api(plugin, L); + install_effects_api(plugin, L); readonly(); lua_setglobal(L, "reskate"); lua_pushcfunction(L, ApiFunctions::print, "print"); diff --git a/Extension/Plugins/plugin_api.h b/Extension/Plugins/plugin_api.h index d7b0aea3..1a73f8be 100644 --- a/Extension/Plugins/plugin_api.h +++ b/Extension/Plugins/plugin_api.h @@ -26,6 +26,7 @@ void install_kit_api(Plugin& plugin, lua_State* L); // Screen captures, the game menu's preview turned and read, what the skater wears, and the data // folder shown (plugin_api_capture.cpp). void install_capture_api(Plugin& plugin, lua_State* L); +void install_effects_api(Plugin& plugin, lua_State* L); // ---- adding to the reskate table --------------------------------------------------------------- // A new part of the API is a file of `static int name(lua_State*)` functions and an install_*_api diff --git a/Extension/Plugins/plugin_api_effects.cpp b/Extension/Plugins/plugin_api_effects.cpp new file mode 100644 index 00000000..99b17de7 --- /dev/null +++ b/Extension/Plugins/plugin_api_effects.cpp @@ -0,0 +1,99 @@ +#include "plugin_api.h" +#include "plugin_effects.h" +#include "lualib.h" +#include +#include + +namespace dingosdk::plugins { +namespace { +EffectMatrix matrix(lua_State* L, int index) { + luaL_checktype(L, index, LUA_TTABLE); + if (lua_objlen(L, index) != 16) luaL_error(L, "a transform has 16 numbers"); + EffectMatrix out{}; + for (int i = 0; i < 16; ++i) { + lua_rawgeti(L, index, i + 1); + out[i] = static_cast(luaL_checknumber(L, -1)); + lua_pop(L, 1); + } + if (!valid_effect_matrix(out)) luaL_error(L, "an effect needs a finite, orthogonal transform with axes between .001 and 20 metres"); + return out; +} +} +struct EffectsApi { + static Plugin& caller(lua_State* L) { + auto& p = plugin_of(L); + need(L, p, "effects", "reskate.effects"); + if (++p.effect_calls_ > 8192) luaL_error(L, "at most 8192 effect operations in a tick"); + return p; + } + static std::uint64_t handle(lua_State* L, Plugin& p) { + const double id = luaL_checknumber(L, 1); + if (!(id >= 1 && id <= 9007199254740991.0) || std::floor(id) != id) + luaL_error(L, "an effect handle is a positive integer"); + const auto value = static_cast(id); + if (!p.effect_handles_.contains(value)) luaL_error(L, "that effect does not belong to this plugin"); + return value; + } + static int create(lua_State* L, bool decal) { + auto& p = caller(L); + EffectRequest request; + request.asset = checked_text(L, 1, 240, "effect asset"); + if (!private_effect_asset(request.asset, decal) || !p.manifest_.effects.contains(request.asset)) + luaL_error(L, "effects must name a declared private mod asset; stock and store effects are refused"); + request.transform = matrix(L, 2); + request.decal = decal; + if (decal && !lua_isnoneornil(L, 3)) { + luaL_checktype(L, 3, LUA_TTABLE); + lua_getfield(L, 3, "attach"); request.attach = lua_toboolean(L, -1) != 0; lua_pop(L, 1); + lua_getfield(L, 3, "lift"); + if (!lua_isnil(L, -1)) request.lift = static_cast(luaL_checknumber(L, -1)); + lua_pop(L, 1); + if (!(request.lift >= 0 && request.lift <= 1)) luaL_error(L, "projection lift is 0 to 1 metre"); + } + const auto count = std::count_if(p.effect_handles_.begin(), p.effect_handles_.end(), [decal](const auto& h) { return h.second == decal; }); + if (count >= (decal ? 2048 : 24)) { + lua_pushnil(L); lua_pushliteral(L, "effect limit reached"); return 2; + } + const auto id = p.services_.game().effect_create(p.id_, request); + if (!id) { lua_pushnil(L); lua_pushliteral(L, "effect asset or renderer unavailable"); return 2; } + p.effect_handles_.emplace(id, decal); + lua_pushnumber(L, static_cast(id)); + return 1; + } + static int particle(lua_State* L) { return create(L, false); } + static int decal(lua_State* L) { return create(L, true); } + static int move(lua_State* L) { + auto& p = caller(L); const auto id = handle(L, p); + const auto transform = matrix(L, 2); + p.services_.game().effect_move(p.id_, id, transform); return 0; + } + static int stop(lua_State* L) { + auto& p = caller(L); const auto id = handle(L, p); + p.services_.game().effect_stop(p.id_, id); return 0; + } + static int opacity(lua_State* L) { + auto& p = caller(L); const auto id = handle(L, p); + const auto alpha = luaL_checknumber(L, 2); + if (!(alpha >= 0 && alpha <= 1)) luaL_error(L, "opacity is 0 to 1"); + p.services_.game().effect_opacity(p.id_, id, static_cast(alpha)); return 0; + } + static int release(lua_State* L) { + auto& p = caller(L); const auto id = handle(L, p); + p.services_.game().effect_release(p.id_, id); p.effect_handles_.erase(id); return 0; + } + static int state(lua_State* L) { + auto& p = caller(L); + const auto [ready, epoch] = p.services_.game().effects_state(); + lua_pushboolean(L, ready); lua_pushnumber(L, static_cast(epoch)); return 2; + } + static int clear(lua_State* L) { + auto& p = caller(L); + p.services_.game().effects_clear(p.id_); p.effect_handles_.clear(); return 0; + } +}; +void install_effects_api(Plugin&, lua_State* L) { + add_functions(L, "effects", {{"particle", EffectsApi::particle}, {"decal", EffectsApi::decal}, + {"move", EffectsApi::move}, {"stop", EffectsApi::stop}, {"opacity", EffectsApi::opacity}, + {"release", EffectsApi::release}, {"clear", EffectsApi::clear}, {"state", EffectsApi::state}}); +} +} diff --git a/Extension/Plugins/plugin_effects.cpp b/Extension/Plugins/plugin_effects.cpp new file mode 100644 index 00000000..fcef4c82 --- /dev/null +++ b/Extension/Plugins/plugin_effects.cpp @@ -0,0 +1,29 @@ +#include "plugin_effects.h" +#include + +namespace dingosdk::plugins { +bool private_effect_asset(std::string_view asset, bool decal) noexcept { + const std::string_view prefix = decal ? "world/materials/decal/reskate_" : "effects/reskate/"; + if (!asset.starts_with(prefix) || asset.size() <= prefix.size() || asset.size() > 240 || + asset.back() == '/' || asset.find("//") != std::string_view::npos) return false; + for (const char c : asset) + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '/' || c == '_' || c == '-')) return false; + return true; +} +bool valid_effect_matrix(const EffectMatrix& m) noexcept { + for (const float v : m) if (!std::isfinite(v)) return false; + if (m[3] != 0 || m[7] != 0 || m[11] != 0 || m[15] != 1) return false; + float lengths[3]{}; + for (unsigned a = 0; a < 3; ++a) { + if (std::abs(m[12 + a]) > 100000) return false; + for (unsigned i = 0; i < 3; ++i) lengths[a] += m[a * 4 + i] * m[a * 4 + i]; + if (lengths[a] < .000001f || lengths[a] > 400) return false; + for (unsigned b = 0; b < a; ++b) { + float dot{}; + for (unsigned i = 0; i < 3; ++i) dot += m[a * 4 + i] * m[b * 4 + i]; + if (std::abs(dot) > .01f * std::sqrt(lengths[a] * lengths[b])) return false; + } + } + return true; +} +} diff --git a/Extension/Plugins/plugin_effects.h b/Extension/Plugins/plugin_effects.h new file mode 100644 index 00000000..23ab058f --- /dev/null +++ b/Extension/Plugins/plugin_effects.h @@ -0,0 +1,18 @@ +#pragma once +#include +#include +#include +#include + +namespace dingosdk::plugins { +using EffectMatrix = std::array; +struct EffectRequest { + std::string asset; + EffectMatrix transform{}; + bool decal{}, attach{}; + float lift{}; +}; +// Private mod assets only. Stock effect blueprints include store cosmetics. +bool private_effect_asset(std::string_view asset, bool decal) noexcept; +bool valid_effect_matrix(const EffectMatrix& matrix) noexcept; +} diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index f2076e0e..6db022ba 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -1,4 +1,5 @@ #pragma once +#include "plugin_effects.h" #include "plugin_wave.h" #include "Engine/Core/Json/json.h" #include "Extension/AnimationStudio/anim_core.h" @@ -112,7 +113,7 @@ struct SkaterNews { std::vector falls; }; // One physics step of the local skater (events.physics): when (the steady clock, ms, as -// reskate.time.now), how long it simulated, and what the body did; only the bodies touching something. +// reskate.time.now), how long it simulated, contacts, and available body poses even in the air. struct PhysicsStep { double at{}; float seconds{}; @@ -124,8 +125,12 @@ struct PhysicsStep { std::string name; // as body_hit names it float impact{}, slide{}; bool board{}, vehicle{}, world{}; // world: anything but the board and vehicles + std::optional point, normal; }; std::vector bodies; + struct Pose { EffectMatrix transform{}; Vec3 velocity{}; }; + std::map> poses; // all available local bodies, including airborne ones + std::optional state; }; // What a plugin sets of a launch into the air, m/s, each if given: across the direction of travel // (+ its left), along it, and up. And a launch as it went (Extension/Skater/launches.h). @@ -258,6 +263,15 @@ struct Progress { class Game { public: virtual ~Game() = default; + // Client-only particles and projected materials. Handles are owned by their plugin. + virtual std::uint64_t effect_create(std::string_view, const EffectRequest&) { return 0; } + virtual void effect_move(std::string_view, std::uint64_t, const EffectMatrix&) {} + virtual void effect_stop(std::string_view, std::uint64_t) {} + virtual void effect_opacity(std::string_view, std::uint64_t, float) {} + virtual void effect_release(std::string_view, std::uint64_t) {} + virtual void effects_clear(std::string_view) {} + virtual std::pair effects_state() { return {false, 0}; } + // ---- the local skater ---------------------------------------------------------------------- // Its physics state (100s riding, 200s in the air, 300 a wipeout, 400s grinding, 504 off the // board), or empty without a skater; and how many wipeouts it has had this session. diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index d51114b9..c686b33b 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -1,4 +1,5 @@ #include "plugin_game_runtime.h" +#include "Extension/Rendering/plugin_effects.h" #include "plugin_session_feed.h" #include "plugin_content.h" #include "Runtime/runtime_internal.h" @@ -583,6 +584,13 @@ public: auto layers = outfit_overlay::layers(plugin); return {std::move(layers.items), std::move(layers.worn), std::move(layers.note)}; } + std::uint64_t effect_create(std::string_view plugin, const EffectRequest& request) override { return effects::create(plugin, request); } + void effect_move(std::string_view plugin, std::uint64_t id, const EffectMatrix& transform) override { effects::move(plugin, id, transform); } + void effect_stop(std::string_view plugin, std::uint64_t id) override { effects::stop(plugin, id); } + void effect_opacity(std::string_view plugin, std::uint64_t id, float value) override { effects::opacity(plugin, id, value); } + void effect_release(std::string_view plugin, std::uint64_t id) override { effects::release(plugin, id); } + void effects_clear(std::string_view plugin) override { effects::clear(plugin); } + std::pair effects_state() override { return effects::status(); } // ---- the skater's physics ------------------------------------------------------------------------ SkaterNews take_skater_news() override { skater_events::want(); @@ -601,16 +609,20 @@ public: std::vector take_physics_steps() override { skater_events::want_steps(); std::vector out; + NoBailSkater owner; + const bool owned = no_bail_skater(owner); for (const auto& news : skater_events::take_steps()) { + if (!owned || news.entity != owner.entity || news.rig != owner.rig) continue; const auto& s = news.step; - PhysicsStep step{news.at, s.seconds, s.wipeout, s.airborne, s.ragdoll, std::nullopt, s.spin, {}}; + PhysicsStep step{news.at, s.seconds, s.wipeout, s.airborne, s.ragdoll, std::nullopt, s.spin, {}, {}, s.state}; if (s.velocity) step.velocity = Vec3{(*s.velocity)[0], (*s.velocity)[1], (*s.velocity)[2]}; for (std::size_t i = 1; i < skater_body::count; ++i) { const auto& c = s.body.bodies[i]; + if (const auto& pose = s.poses[i]) step.poses.emplace(skater_body::names[i], PhysicsStep::Pose{pose->transform, pose->velocity}); if (!c.touching) continue; const float slide = std::sqrt(c.slide[0] * c.slide[0] + c.slide[1] * c.slide[1] + c.slide[2] * c.slide[2]); step.bodies.push_back({skater_body::names[i], c.impact, std::isfinite(slide) ? slide : 0.0f, c.hit.board, c.hit.vehicle, - c.hit.world || c.hit.kind_5 || c.hit.kind_11}); + c.hit.world || c.hit.kind_5 || c.hit.kind_11, c.point, c.normal}); } out.push_back(std::move(step)); } @@ -1619,6 +1631,7 @@ public: if (competition_owner_ == plugin) competition(plugin, false); stop_clip(plugin); plugin_sound::stop_all(plugin); + effects::clear(plugin); outfit_overlay::clear(plugin); item_browser::release_owner(plugin); (void)outfit_overlay::set_layers(std::string(plugin), {}); diff --git a/Extension/Plugins/plugin_host.cpp b/Extension/Plugins/plugin_host.cpp index 3a45e633..2cae50d8 100644 --- a/Extension/Plugins/plugin_host.cpp +++ b/Extension/Plugins/plugin_host.cpp @@ -297,6 +297,7 @@ void Host::follow_game() { data["at"] = step.at; data["seconds"] = step.seconds; data["wipeout"] = step.wipeout; + if (step.state) data["state"] = *step.state; data["airborne"] = step.airborne; if (step.ragdoll) data["ragdoll"] = *step.ragdoll; if (step.velocity) data["velocity"] = Json::array({(*step.velocity)[0], (*step.velocity)[1], (*step.velocity)[2]}); @@ -309,9 +310,20 @@ void Host::follow_game() { body["board"] = b.board; body["vehicle"] = b.vehicle; body["world"] = b.world; + if (b.point) body["point"] = Json::array({(*b.point)[0], (*b.point)[1], (*b.point)[2]}); + if (b.normal) body["normal"] = Json::array({(*b.normal)[0], (*b.normal)[1], (*b.normal)[2]}); bodies[b.name] = std::move(body); } data["bodies"] = std::move(bodies); + auto poses = Json::object(); + for (const auto& [name, p] : step.poses) { + auto pose = Json::object(), matrix = Json::array(); + for (const float v : p.transform) matrix.push_back(v); + pose["transform"] = std::move(matrix); + pose["velocity"] = Json::array({p.velocity[0], p.velocity[1], p.velocity[2]}); + poses[name] = std::move(pose); + } + data["poses"] = std::move(poses); each([&](Plugin& plugin, const std::string&) { plugin.game_event("physics_step", "events.physics", data); }); } if (wants("events.skater")) diff --git a/Extension/Plugins/plugin_manifest.cpp b/Extension/Plugins/plugin_manifest.cpp index 58376927..99f84c1b 100644 --- a/Extension/Plugins/plugin_manifest.cpp +++ b/Extension/Plugins/plugin_manifest.cpp @@ -1,4 +1,5 @@ #include "plugin_manifest.h" +#include "plugin_effects.h" #include "Engine/Core/Json/json.h" #include #include @@ -16,13 +17,13 @@ namespace { // (reskate.server.parks, park_spawn, park_remove); "variants" choosing which of its own mod's // variants is in the game (reskate.package.choose). A manifest's "resident" list and "variants" are // the merge's (Engine/Vfs/mod_list.h ResidentAssets, Variants), not the plugin host's. -constexpr std::array permissions{ +constexpr std::array permissions{ "console", "storage", "overlay", "menu", "ui.screen", "input", "hud", "events.tricks", "events.skater", "skater.read", "skater.teleport", "skater.control", "skater.animation", "trainer", "camera", "world.raycast", "world.objects", "world.entities", "world.environment", "world.ai", "travel", "session.read", "session.skaters", "session.nametags", "net.session", "net.http", "voice.control", "chat.read", "chat.send", "chat.local", "server", "server.parks", "throwdowns", "replay", "music", "progression.read", "settings.read", "audio.ui", "audio", "outfit", "board", "mods.read", - "events.physics", "game.speed", "files", "variants", "settings.write", "room", "capture"}; + "events.physics", "game.speed", "files", "variants", "settings.write", "room", "capture", "effects"}; } bool known_permission(std::string_view name) noexcept { @@ -94,6 +95,16 @@ Manifest parse_manifest(std::string_view text) { result.permissions.insert(name); } } + if (json.contains("effects")) { + const auto& list = json.at("effects"); + if (!result.permissions.contains("effects") || !list.is_array() || list.size() > 128) + throw std::runtime_error("effects needs the effects permission and at most 128 private asset names"); + for (const auto& item : list) { + if (!item.is_string() || (!private_effect_asset(item.string(), false) && !private_effect_asset(item.string(), true))) + throw std::runtime_error("effects accepts private reskate particle/material paths only; stock and store assets are refused"); + result.effects.insert(item.string()); + } + } if (json.contains("domains")) { const auto& list = json.at("domains"); if (!list.is_array() || list.size() > 16) throw std::runtime_error("\"domains\" must be a list of at most 16 host names"); diff --git a/Extension/Plugins/plugin_manifest.h b/Extension/Plugins/plugin_manifest.h index d92d997a..b393c824 100644 --- a/Extension/Plugins/plugin_manifest.h +++ b/Extension/Plugins/plugin_manifest.h @@ -22,6 +22,7 @@ struct Manifest { std::set> permissions; // net.http: the exact host names its web requests may go to ("api.example.com"), lowercase. std::vector domains; + std::set> effects; // exact private particle/material asset names // mods.read: the folders it reads in every enabled mod ("parks": /parks/...), lowercase // (reskate.mods.content and read_content). Other mods ship data for it there, with no code. std::vector content; diff --git a/Extension/Plugins/plugin_vm.cpp b/Extension/Plugins/plugin_vm.cpp index fdbc0841..e20332d0 100644 --- a/Extension/Plugins/plugin_vm.cpp +++ b/Extension/Plugins/plugin_vm.cpp @@ -377,6 +377,7 @@ void Plugin::tick(double dt_ms) { if (state_ != State::running) return; ++tick_number_; raycasts_ = 0; + effect_calls_ = 0; const double now = services_.now_ms(); std::vector due; for (const auto& [id, timer] : timers_) diff --git a/Extension/Plugins/plugin_vm.h b/Extension/Plugins/plugin_vm.h index 47ba0c80..f741c65c 100644 --- a/Extension/Plugins/plugin_vm.h +++ b/Extension/Plugins/plugin_vm.h @@ -274,6 +274,7 @@ private: friend struct ServicesApi; friend struct KitApi; friend struct CaptureApi; + friend struct EffectsApi; struct Subcommand { std::string name, args, help; }; struct Command { std::string help; int function{}; std::vector subcommands; }; struct Timer { double due{}, every{}; int function{}; }; @@ -325,6 +326,8 @@ private: std::vector errors_; double log_second_{}; int log_lines_{}; + std::map effect_handles_; // bool: decal + int effect_calls_{}; int raycasts_{}; // since the last tick std::optional touch_reach_; std::map contacts_; diff --git a/Extension/Rendering/Test/effect_geometry_tests.cpp b/Extension/Rendering/Test/effect_geometry_tests.cpp new file mode 100644 index 00000000..e282b6c8 --- /dev/null +++ b/Extension/Rendering/Test/effect_geometry_tests.cpp @@ -0,0 +1,135 @@ +#include "Extension/Plugins/plugin_effects.h" +#include "Extension/Rendering/effect_surface.h" +#include "Extension/Rendering/effect_asset.h" +#include "Engine/Game/Settings/transient_count_lease.h" +#include "Engine/Game/Abi/native_data.h" +#include "Extension/Skater/skater_physics.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/skater_body.h" +#include "Engine/Game/Build/20260929/skater_state.h" +#include +#include +#include +#include +#include +#include + +namespace dingosdk { +bool no_bail_available() noexcept { return true; } +bool no_bail_skater(NoBailSkater&) noexcept { return false; } +} +namespace { +using namespace dingosdk; +void check(bool ok, const char* why) { if (!ok) throw std::runtime_error(why); } +template void put(std::vector& bytes, std::size_t at, const T& value) { + check(at + sizeof(T) <= bytes.size(), "fixture field outside allocation"); + std::memcpy(bytes.data() + at, &value, sizeof(T)); +} +std::uintptr_t address(const std::vector& bytes) { return reinterpret_cast(bytes.data()); } + +void effects_refuse_the_wrong_native_asset_type() { + // Identity words read from the supported executable and confirmed on the + // loaded private assets. Computing these with native_name_hash hid the bug. + std::uint32_t record = 0xf258c6f9; + const auto type = reinterpret_cast(&record); + std::vector asset(32); + put(asset, addr::effects::asset_type, reinterpret_cast(&type)); + check(effects::asset_type_matches(address(asset), false), "particle blueprints are accepted"); + check(!effects::asset_type_matches(address(asset), true), "a particle is never treated as a decal"); + record = 0x5de4ae3c; + check(effects::asset_type_matches(address(asset), true), "decal templates are accepted"); + record = game::native_name_hash("TextureAsset"); + check(!effects::asset_type_matches(address(asset), true) && !effects::asset_type_matches(address(asset), false), + "a private name cannot make a texture into an effect"); + record = game::native_name_hash("EffectBlueprint"); + check(!effects::asset_type_matches(address(asset), false), "a native function-name hash is not an asset type identity"); + put(asset, addr::effects::asset_type, std::uintptr_t{}); + check(!effects::asset_type_matches(address(asset), false) && !effects::asset_type_matches(0, true), + "missing reflection fails closed"); +} + +void geometry_is_from_the_local_physics_step() { + namespace b = addr::skater_body; + namespace s = addr::skater_state; + std::vector rig(0x5000), physics(0x2000), bodies(26 * 0x130), contacts(0x2000), core(0x500), context(0x2000); + constexpr std::uintptr_t base = 0x140000000; + put(rig, s::rig_physics_offset, address(physics)); + put(physics, 0, base + s::rig_physics_vtable); + put(physics, s::physics_bodies_offset, address(bodies)); + put(physics, b::contact_struct_offset, address(contacts)); + put(bodies, 0, s::rig_body_count); + put(context, s::physics_state_offset, std::uint32_t{300}); + const plugins::EffectMatrix transform{1,0,0,0,0,1,0,0,0,0,1,0,2,.15f,4,1}; + const game::Vec3 velocity{5,-12,2}; + for (std::size_t i = 1; i < skater_body::count; ++i) { + put(bodies, i*s::physics_body_size+s::body_owner_offset, address(physics)); + put(bodies, i*s::physics_body_size+b::body_transform_offset, transform); + put(bodies, i*s::physics_body_size+s::body_velocity_offset, velocity); + } + put(contacts, b::bone_touching_offset+1, std::uint8_t{1}); + const auto record = b::bone_records_offset+b::bone_record_size; + put(contacts, record+b::bone_peak_offset, 7.0f); + put(contacts, record+b::bone_tracked_peak_offset, 12.0f); + put(contacts, record+b::bone_normal_offset, std::array{1,0,0,0}); + put(contacts, record+b::bone_normal_offset+b::bone_tracked_offset, std::array{0,1,0,0}); + put(contacts, record+b::bone_point_offset, std::array{2,0,4,0}); + put(contacts, record+b::bone_hit_vehicle_offset, std::uint8_t{1}); + NoBailSkater skater{base, 0, address(core), address(context), address(rig)}; + auto sample = skater_physics::read_step(skater, .05f, true); + const auto& c = sample.body.bodies[1]; + check(c.touching && c.impact == 12 && c.hit.vehicle, "the largest contact and receiver kind survive"); + check(c.normal == game::Vec3{0,1,0} && c.point == game::Vec3{2,0,4}, "normal comes from the same tracked peak, point from the verified field"); + check(sample.poses[1] && sample.poses[1]->transform == transform && sample.poses[1]->velocity == velocity, "poses and velocities are copied"); + check(sample.poses[23].has_value() && !sample.body.bodies[23].touching, "airborne body poses remain available for attached emitters"); + check(sample.state == 300u, "the transition into a bail retains its selector state"); + put(bodies, s::physics_body_size+s::body_owner_offset, std::uintptr_t{}); + put(contacts, record+b::bone_normal_offset+b::bone_tracked_offset, std::array{std::numeric_limits::quiet_NaN(),0,0,0}); + sample = skater_physics::read_step(skater, .05f, true); + check(!sample.poses[1] && sample.poses[23], "a broken owner chain is never published as a body pose"); + check(sample.body.bodies[1].touching && !sample.body.bodies[1].normal, "unknown contact geometry keeps the raw touching evidence"); + put(bodies, 0, std::uint32_t{24}); + sample = skater_physics::read_step(skater, .05f, true); + check(!sample.poses[23], "a different physics layout fails closed"); +} +void projections_follow_receivers() { + using namespace effects; + Matrix pose{}; + check(surface::pose({0,.70710678f,0,.70710678f},{10,2,3,0},pose), "a receiver's quaternion makes a rigid pose"); + const Matrix decal{.3f,0,0,0,0,.08f,0,0,0,0,.7f,0,11,2,4,1}; + const auto local = surface::local(pose,decal); + const auto original = surface::world(pose,local); + for (std::size_t i = 0; i < 16; ++i) check(std::abs(original[i]-decal[i])<.0001f, "projection round trip preserves its shape"); + pose[12]+=5; + const auto moved = surface::world(pose,local); + check(std::abs(moved[12]-16)<.0001f && surface::changed(original,moved), "the stain follows a moved vehicle"); + check(!surface::pose({0,0,0,0},{0,0,0,0},pose), "invalid receiver rotations are refused"); + check(plugins::valid_effect_matrix(decal), "a thin projector is permitted"); + auto invalid=decal; invalid[12]=100001; + check(!plugins::valid_effect_matrix(invalid), "effects cannot leave the bounded world"); + invalid=decal; invalid[5]=0; + check(!plugins::valid_effect_matrix(invalid), "zero depth is refused"); +} +void draw_allowance_restores_only_its_own_setting() { + CountSettingSample value{1,2,3,120}; + const auto read=[&]() -> std::optional { return value; }; + const auto write=[&](const CountSettingSample& expected, std::uint32_t next) { + if (value!=expected) return false; + value.value=next; return true; + }; + TransientCountLease lease; + check(lease.update(2048,read,write) && value.value==2168, "decal allowance is additive"); + check(lease.restore(read,write) && value.value==120, "disabling restores the renderer's original budget"); + check(lease.update(2048,read,write), "the allowance can be reacquired"); + value.value=4000; + check(!lease.update(2048,read,write), "an external settings edit relinquishes ownership"); + check(lease.restore(read,write) && value.value==4000, "cleanup does not overwrite the player's later setting"); + check(!lease.update(4097,read,write), "the temporary allowance is bounded"); +} +} +int main() { + try { + effects_refuse_the_wrong_native_asset_type(); geometry_is_from_the_local_physics_step(); + projections_follow_receivers(); draw_allowance_restores_only_its_own_setting(); + std::cout << "effect geometry tests passed\n"; return 0; + } catch (const std::exception& e) { std::cerr << e.what() << '\n'; return 1; } +} diff --git a/Extension/Rendering/effect_asset.h b/Extension/Rendering/effect_asset.h new file mode 100644 index 00000000..41403de2 --- /dev/null +++ b/Extension/Rendering/effect_asset.h @@ -0,0 +1,17 @@ +#pragma once +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/effects.h" + +namespace dingosdk::effects { +inline bool asset_type_matches(std::uintptr_t asset, bool decal) noexcept { + // Named lookup can return any asset class. Check its reflected type before + // passing it to an API that expects a blueprint or a decal template. + std::uintptr_t type{}, record{}; + std::uint32_t hash{}; + return asset && asset < memory::highest_user_address - addr::effects::asset_type && + memory::peek(asset + addr::effects::asset_type, type) && memory::peek(type, record) && + memory::peek(record, hash) && hash == + (decal ? addr::effects::decal_type_hash : addr::effects::particle_type_hash); +} +} diff --git a/Extension/Rendering/effect_draw_budget.h b/Extension/Rendering/effect_draw_budget.h new file mode 100644 index 00000000..e87bbac3 --- /dev/null +++ b/Extension/Rendering/effect_draw_budget.h @@ -0,0 +1,69 @@ +#pragma once +#include "Engine/Game/Settings/transient_count_lease.h" +#include "Engine/Game/Build/20260929/named_settings.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Core/Platform/memory.h" +#include +#include +#include + +namespace dingosdk::effects { +// Native renderer truncates whole later material groups at these limits. +// Read-only live recon: Low environment count 120, total view count 256. +// The named typed setter notifies the engine to resize its render buffers. +class DrawBudget { +public: + bool update(std::uintptr_t base,std::uint32_t extra) noexcept { + namespace contract=addr::named_settings; + if (!checked_) { + base_=base; checked_=true; + valid_=std::all_of(contract::named_settings_sites.begin(),contract::named_settings_sites.end(),[&](const auto& site) { + std::array bytes{}; + return memory::read_bytes(base+site.rva,bytes.data(),bytes.size()) && bytes==site.bytes; + }); + } + if (!valid_ || base!=base_) return false; + bool ready=true; + for (std::size_t i=0;i sample(std::uintptr_t base,const char* name) noexcept { + namespace contract=addr::named_settings; + CountSettingSample result; + if (!memory::read(base+contract::named_settings_manager,result.manager) || !result.manager) return {}; + using Getter=std::uintptr_t(*)(std::uintptr_t,const char*,std::uintptr_t*,bool); + __try {result.address=reinterpret_cast(base+contract::named_settings_get)(result.manager,name,&result.type,false);} + __except(EXCEPTION_EXECUTE_HANDLER) {return {};} + if (!result.address || (result.type!=base+contract::native_uint32 && result.type!=base+contract::native_int32) || + !memory::read(result.address,result.value) || result.value>65536) return {}; + return result; + } + static bool assign(std::uintptr_t base,const char* name,const CountSettingSample& expected,std::uint32_t value) noexcept { + const auto current=sample(base,name); + if (!current || *current!=expected) return false; + return write_native(base,name,expected.manager,value); + } + static bool write_native(std::uintptr_t base,const char* name,std::uintptr_t manager,std::uint32_t value) noexcept { + namespace contract=addr::named_settings; + using Setter=bool(*)(std::uintptr_t,const char*,std::uintptr_t,const void*); + __try {return reinterpret_cast(base+contract::named_settings_set)(manager,name,0,&value);} + __except(EXCEPTION_EXECUTE_HANDLER) {return false;} + } + std::array leases_; + std::uintptr_t base_{}; + bool checked_{},valid_{}; +}; +} diff --git a/Extension/Rendering/effect_receivers.h b/Extension/Rendering/effect_receivers.h new file mode 100644 index 00000000..685796d4 --- /dev/null +++ b/Extension/Rendering/effect_receivers.h @@ -0,0 +1,89 @@ +#pragma once +#include "effect_surface.h" +#include "Extension/Objects/ParkEditor/park_editor_surface.h" +#include "Engine/Game/Build/20260929/effect_surface.h" +#include + +namespace dingosdk::effects { +namespace receiver_build = addr::effect_surface; +// Called only on the native effect/decal game thread. Physics identities include +// a generation, so deleting a prop cannot attach its marks to a recycled slot. +class Receivers { + editor::NativeSurfaceApi api_; + std::uintptr_t base_{},world_{}; + std::unordered_map> poses_; + template F function(std::uintptr_t rva) const {return reinterpret_cast(base_+rva);} +public: + bool initialize(std::uintptr_t base) { + base_=base; api_=editor::surface_api(base); + for (const auto& c:receiver_build::contracts) { + std::array bytes{}; + if (!memory::read_bytes(base+c.rva,bytes.data(),bytes.size()) || bytes!=c.bytes) return false; + } + return api_.ready; + } + void begin(std::uintptr_t context) {poses_.clear(); world_=context ? api_.world(context) : 0;} + bool pose(const ReceiverId& id,Matrix& out) { + if (!world_ || id[0]!=world_) return false; + auto [at,inserted]=poses_.try_emplace(id[1]); + if (inserted) { + namespace build=receiver_build; + alignas(16) const auto body=id; + if (function(build::contracts[1].rva)(body.data())) { + alignas(16) std::array position{},rotation{}; + function(build::body_position)(body.data(),position.data()); + function(build::body_rotation)(body.data(),rotation.data()); + Matrix matrix; + if (surface::pose(rotation,position,matrix)) at->second=matrix; + } + } + if (!at->second) return false; + out=*at->second; return true; + } + bool bind(const Matrix& transform, float lift, Receiver& receiver) { + if (!world_) return false; + const auto& t=transform; + const float depth=std::sqrt(t[4]*t[4]+t[5]*t[5]+t[6]*t[6]); + if (!std::isfinite(depth) || depth<.001f) return false; + editor::NativeSurfaceRay ray; + for (unsigned i=0;i<3;++i) { + const float normal=t[4+i]/depth,point=t[12+i]-normal*lift; + ray.start[i]=point+normal*.03f; + ray.end[i]=point-normal*.06f; + } + editor::NativeSurfaceResult result; + api_.ray(world_,&result,&ray,"ReSkate_Receiver"); + struct Cleanup { + const editor::NativeSurfaceApi& api; editor::NativeSurfaceResult& result; + ~Cleanup() { + if (result.scope[2]) { + const auto allocator=api.scope_allocator(); + std::uintptr_t vtable{},pop{}; + if (memory::peek(allocator,vtable) && memory::peek(vtable+receiver_build::scope_pop_slot,pop) && pop) + reinterpret_cast(pop)(allocator,result.scope.data()); + } + if (result.allocator) api.release(&result.allocator_vtable,result.data); + } + } cleanup{api_,result}; + if (result.world!=world_ || result.count>4096 || result.first>UINT32_MAX-result.count) return false; + if (!result.count) return false; + if (!result.data) return false; + std::uintptr_t fractions{}; + if (!memory::peek(result.data+receiver_build::result_fractions,fractions) || !fractions) return false; + float closest=2; std::uint32_t index{}; + for (std::uint32_t n=0;n=0 && fraction<=1 && fraction1) return false; + alignas(16) const std::array hit{world_,index,result.data}; + alignas(16) ReceiverId body{}; + function(receiver_build::contracts[0].rva)(hit.data(),body.data()); + if (!pose(body,receiver.pose)) return false; + receiver.id=body; return true; + } +}; +} diff --git a/Extension/Rendering/effect_surface.h b/Extension/Rendering/effect_surface.h new file mode 100644 index 00000000..f29d3e24 --- /dev/null +++ b/Extension/Rendering/effect_surface.h @@ -0,0 +1,55 @@ +#pragma once +#include "Extension/Plugins/plugin_effects.h" +#include +#include +namespace dingosdk::effects { +using Matrix = plugins::EffectMatrix; +using ReceiverId = std::array; +struct Receiver {ReceiverId id{}; Matrix pose{};}; +} +#include +#include + +namespace dingosdk::effects::surface { +inline bool valid_pose(const Matrix& m) { + for (float v:m) if (!std::isfinite(v)) return false; + for (unsigned a=0;a<3;++a) { + if (std::abs(m[12+a])>100000 || std::abs(m[4*a+3])>.001f) return false; + for (unsigned b=0;b<=a;++b) { + float dot{}; for (unsigned i=0;i<3;++i) dot+=m[4*a+i]*m[4*b+i]; + if (std::abs(dot-(a==b ? 1.f : 0.f))>.01f) return false; + } + } + return std::abs(m[15]-1)<.001f; +} +inline Matrix local(const Matrix& pose,const Matrix& world) { + Matrix out{}; out[15]=1; + for (unsigned column=0;column<4;++column) for (unsigned row=0;row<3;++row) + for (unsigned i=0;i<3;++i) + out[4*column+row]+=pose[4*row+i]*(world[4*column+i]-(column==3 ? pose[12+i] : 0.f)); + return out; +} +inline Matrix world(const Matrix& pose,const Matrix& local) { + Matrix out{}; out[15]=1; + for (unsigned column=0;column<4;++column) for (unsigned row=0;row<3;++row) { + out[4*column+row]=column==3 ? pose[12+row] : 0.f; + for (unsigned i=0;i<3;++i) out[4*column+row]+=pose[4*i+row]*local[4*column+i]; + } + return out; +} +inline bool changed(const Matrix& a,const Matrix& b) { + for (unsigned i=0;i<16;++i) if (std::abs(a[i]-b[i])>.00001f) return true; + return false; +} +// Native physics readers return quaternion XYZW and world position XYZ. +inline bool pose(const std::array& rotation,const std::array& position,Matrix& out) { + float norm{}; + for (float v:rotation) {if (!std::isfinite(v)) return false; norm+=v*v;} + if (norm<.9f || norm>1.1f) return false; + const float scale=1/std::sqrt(norm),x=rotation[0]*scale,y=rotation[1]*scale,z=rotation[2]*scale,w=rotation[3]*scale; + out={1-2*(y*y+z*z),2*(x*y+z*w),2*(x*z-y*w),0, + 2*(x*y-z*w),1-2*(x*x+z*z),2*(y*z+x*w),0, + 2*(x*z+y*w),2*(y*z-x*w),1-2*(x*x+y*y),0,position[0],position[1],position[2],1}; + return valid_pose(out); +} +} diff --git a/Extension/Rendering/plugin_effects.cpp b/Extension/Rendering/plugin_effects.cpp new file mode 100644 index 00000000..a26bc4cf --- /dev/null +++ b/Extension/Rendering/plugin_effects.cpp @@ -0,0 +1,227 @@ +#include "plugin_effects.h" +#include "effect_receivers.h" +#include "effect_draw_budget.h" +#include "effect_asset.h" +#include "Extension/Decals/native_decals.h" +#include "Extension/Skater/no_bail.h" +#include "Engine/Game/Abi/native_data.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/effects.h" +#include "Engine/Game/Build/20260929/engine.h" +#include "Engine/Game/Build/20260929/replay_activity.h" +#include "Engine/Game/Build/supported_build.h" +#include +#include +#include +#include +#include +#include + +namespace dingosdk::effects { +namespace { +namespace fx = addr::effects; +namespace decal = decals::native; +struct Entry { + std::string owner; + std::uintptr_t particle{}; + decal::Handle decal; + ReceiverId receiver{}; + Matrix local{}, drawn{}; + std::uint64_t expires{}; + bool stopped{}; +}; +struct State { + std::mutex mutex; + std::map entries; + std::map, std::less<>> assets; + Receivers receivers; + DrawBudget budget; + std::uintptr_t base{}, service{}, manager{}, world{}, entity{}; + std::uint64_t serial{}, budget_at{}, epoch{1}; + DWORD thread{}; + std::atomic suspended{true}; + bool checked{}, valid{}, allowed{}; +}; +State& state() { static auto* s = new State; return *s; } +template F function(const State& s, std::uintptr_t rva) { return reinterpret_cast(s.base + rva); } +std::uintptr_t pointer(std::uintptr_t address) { std::uintptr_t value{}; memory::peek(address, value); return value; } +std::uintptr_t service(const State& s) { + const auto p = pointer(s.base + fx::service), vtable = pointer(p); + if (!p || !vtable) return 0; + for (const auto slot : fx::service_methods) { + const auto method = pointer(vtable + slot); + if (method < s.base || method >= s.base + supported_build::game_image_size) return 0; + } + return p; +} +bool same_services(const State& s) { return service(s) == s.service && decal::manager(s.base) == s.manager; } +void destroy(State& s, Entry& e) { + if (e.particle) { + function(s, fx::stop.rva)(&e.particle, true, 0); + function(s, fx::release.rva)(&e.particle); + } + if (e.decal.id) decal::release(s.base, e.decal); +} +void reset(State& s, bool same_world = true) { + if (s.allowed || !s.entries.empty()) ++s.epoch; + if (same_world && s.valid && s.thread == GetCurrentThreadId() && same_services(s)) + for (auto& [id, e] : s.entries) destroy(s, e); + s.entries.clear(); s.assets.clear(); + if (s.valid && s.thread == GetCurrentThreadId()) (void)s.budget.update(s.base, 0); + s.allowed = false; +} +bool usable(const State& s) { return s.valid && s.allowed && !s.suspended && s.thread == GetCurrentThreadId() && same_services(s); } +Entry* owned(State& s, std::string_view plugin, std::uint64_t id) { + const auto it = s.entries.find(id); + return usable(s) && it != s.entries.end() && it->second.owner == plugin ? &it->second : nullptr; +} +std::uintptr_t asset(State& s, const std::string& name) { + if (!s.assets.contains(name) && s.assets.size() >= 512) return 0; + auto& cached = s.assets[name]; + const auto now = GetTickCount64(); + if (cached.first || now < cached.second) return cached.first; + cached.second = now + 2000; + if (const auto find = game::native_data().find_asset) + for (std::uint16_t domain = 0; domain < fx::domain_count; ++domain) + if (pointer(s.base + addr::engine::domain_owners + sizeof(std::uintptr_t) * domain)) + if (const auto found = find(domain, name.c_str())) { cached.first = found; break; } + return cached.first; +} +bool replaying(std::uintptr_t base) { + namespace r = addr::replay_activity; + const auto client = pointer(base + r::client), manager = pointer(client + r::manager); + const auto lease = pointer(manager + r::lease); + return lease && pointer(lease) == base + r::lease_vtable && pointer(lease + r::lease_manager) == manager; +} +} +void tick(std::uintptr_t base, std::uintptr_t client, bool ready, bool noclip, bool editor) noexcept { + try { + auto& s = state(); std::lock_guard lock(s.mutex); + if (!s.thread) s.thread = GetCurrentThreadId(); + if (s.thread != GetCurrentThreadId()) return; + if (!s.checked) { + s.base = base; s.checked = true; s.valid = base != 0; + for (const auto& f : {fx::descriptor_init, fx::descriptor_destroy, fx::create, fx::move, fx::stop, fx::release}) { + std::array bytes{}; + s.valid = memory::read(base + f.rva, bytes) && bytes == f.bytes && s.valid; + } + s.valid = s.valid && decal::contracts_match(base) && decal::opacity_contracts_match(base) && + decal::surface_contracts_match(base) && s.receivers.initialize(base); + } + if (!s.valid || base != s.base) return; + const auto current = service(s), manager = decal::manager(base); + NoBailSkater skater; + const bool playing = ready && !s.suspended && !noclip && !editor && !replaying(base) && no_bail_skater(skater); + const auto world = pointer(client + fx::client_world); + // The transition hook releases references before teardown. If a world + // was replaced without that hook, never submit its old IDs to a new one. + if (!playing || current != s.service || manager != s.manager || world != s.world || skater.entity != s.entity) + reset(s, world == s.world); + s.service = current; s.manager = manager; s.world = world; s.entity = skater.entity; + s.allowed = playing && world && current && manager; + if (!s.allowed) return; + s.receivers.begin(world); + const auto now = GetTickCount64(); + for (auto it = s.entries.begin(); it != s.entries.end();) { + auto& e = it->second; + Matrix pose{}; + if (now >= e.expires || (e.receiver[0] && !s.receivers.pose(e.receiver, pose))) { + destroy(s, e); it = s.entries.erase(it); continue; + } + if (e.receiver[0]) { + const auto transform = surface::world(pose, e.local); + if (surface::changed(transform, e.drawn)) { + decal::set_transform(base, manager, e.decal.id, transform); e.drawn = transform; + } + } + ++it; + } + if (now >= s.budget_at) { + const auto count = std::count_if(s.entries.begin(), s.entries.end(), [](const auto& e) { return e.second.decal.id != 0; }); + (void)s.budget.update(base, count ? 4096 : 0); s.budget_at = now + 1000; + } + } catch (...) {} +} +void before_level_transition(unsigned next) noexcept { + auto& s = state(); + if (next == 3 || next == 14 || next == 22 || next == 24 || next == 25) { + s.suspended = true; + try { std::lock_guard lock(s.mutex); reset(s); } catch (...) {} + } else if (next == 13 || next == 21) s.suspended = false; +} +std::uint64_t create(std::string_view plugin, const plugins::EffectRequest& r) { + auto& s = state(); std::lock_guard lock(s.mutex); + if (!usable(s) || !plugins::private_effect_asset(r.asset, r.decal) || !plugins::valid_effect_matrix(r.transform) || + !(r.lift >= 0 && r.lift <= 1)) return 0; + const auto total = std::count_if(s.entries.begin(), s.entries.end(), [&](const auto& e) { + return (e.second.decal.id != 0) == r.decal; + }); + if (total >= (r.decal ? 4096 : 64)) return 0; + const auto mine = std::count_if(s.entries.begin(), s.entries.end(), [&](const auto& e) { + return e.second.owner == plugin && (e.second.decal.id != 0) == r.decal; + }); + if (mine >= (r.decal ? 2048 : 24)) return 0; + const auto found = asset(s, r.asset); + if (!asset_type_matches(found, r.decal)) return 0; + Entry e; e.owner = plugin; e.expires = GetTickCount64() + 600000; + e.drawn = r.transform; + if (r.decal) { + if (r.attach) { + Receiver receiver; + if (!s.receivers.bind(r.transform, r.lift, receiver)) return 0; + e.receiver = receiver.id; e.local = surface::local(receiver.pose, r.transform); + } + e.decal = decal::create(s.base, s.manager, found, r.transform, decal::ReceiverMask::all); + if (!e.decal.id) return 0; + if (mine == 0) { (void)s.budget.update(s.base, 4096); s.budget_at = GetTickCount64() + 1000; } + } else { + alignas(16) std::array descriptor{}; + alignas(16) const auto transform = r.transform; + function(s, fx::descriptor_init.rva)(descriptor.data(), found, transform.data()); + std::memcpy(descriptor.data() + fx::descriptor_flags, &fx::descriptor_flags_value, sizeof(fx::descriptor_flags_value)); + function(s, fx::create.rva)(0, &e.particle, descriptor.data()); + function(s, fx::descriptor_destroy.rva)(descriptor.data()); + if (!e.particle) return 0; + } + const auto id = ++s.serial; + s.entries.emplace(id, std::move(e)); return id; +} +void move(std::string_view plugin, std::uint64_t id, const Matrix& matrix) { + if (!plugins::valid_effect_matrix(matrix)) return; + auto& s = state(); std::lock_guard lock(s.mutex); + if (auto* e = owned(s, plugin, id)) { + if (e->particle) { alignas(16) const auto m = matrix; function(s, fx::move.rva)(&e->particle, m.data()); } + else if (!e->receiver[0]) decal::set_transform(s.base, s.manager, e->decal.id, matrix); + } +} +void stop(std::string_view plugin, std::uint64_t id) { + auto& s = state(); std::lock_guard lock(s.mutex); + if (auto* e = owned(s, plugin, id); e && e->particle && !e->stopped) { + function(s, fx::stop.rva)(&e->particle, false, 0); e->stopped = true; + } +} +void opacity(std::string_view plugin, std::uint64_t id, float value) { + if (!(value >= 0 && value <= 1)) return; + auto& s = state(); std::lock_guard lock(s.mutex); + if (auto* e = owned(s, plugin, id); e && e->decal.id) decal::set_opacity(s.base, s.manager, e->decal.id, value); +} +void release(std::string_view plugin, std::uint64_t id) { + auto& s = state(); std::lock_guard lock(s.mutex); + const auto it = s.entries.find(id); + if (it == s.entries.end() || it->second.owner != plugin) return; + if (s.valid && s.thread == GetCurrentThreadId() && same_services(s)) destroy(s, it->second); + s.entries.erase(it); +} +std::pair status() { + auto& s = state(); std::lock_guard lock(s.mutex); return {usable(s), s.epoch}; +} +void clear(std::string_view plugin) { + auto& s = state(); std::lock_guard lock(s.mutex); + for (auto it = s.entries.begin(); it != s.entries.end();) { + if (it->second.owner != plugin) { ++it; continue; } + if (s.valid && s.thread == GetCurrentThreadId() && same_services(s)) destroy(s, it->second); + it = s.entries.erase(it); + } + if (s.valid && s.thread == GetCurrentThreadId() && s.entries.empty()) (void)s.budget.update(s.base, 0); +} +} diff --git a/Extension/Rendering/plugin_effects.h b/Extension/Rendering/plugin_effects.h new file mode 100644 index 00000000..2163f721 --- /dev/null +++ b/Extension/Rendering/plugin_effects.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include "Extension/Plugins/plugin_effects.h" + +// The game-thread owner of plugins' native effects. No native reference crosses into a VM. +namespace dingosdk::effects { +void tick(std::uintptr_t base, std::uintptr_t client, bool ready, bool noclip, bool editor) noexcept; +void before_level_transition(unsigned next) noexcept; +std::uint64_t create(std::string_view plugin, const plugins::EffectRequest& request); +void move(std::string_view plugin, std::uint64_t id, const plugins::EffectMatrix& matrix); +void stop(std::string_view plugin, std::uint64_t id); +void opacity(std::string_view plugin, std::uint64_t id, float value); +void release(std::string_view plugin, std::uint64_t id); +void clear(std::string_view plugin); +std::pair status(); +} diff --git a/Extension/Skater/skater_events.cpp b/Extension/Skater/skater_events.cpp index 45328619..5223c903 100644 --- a/Extension/Skater/skater_events.cpp +++ b/Extension/Skater/skater_events.cpp @@ -21,7 +21,7 @@ constexpr std::size_t cause_count = 35; constexpr float heard_hit = 3.0f; // m/s: lighter touches are not news (Board Break's body_hit) constexpr float fall_at_least = 0.4f, fall_at_most = 3.0f; constexpr std::size_t most_steps = 512; -std::atomic steps_wanted{}; +std::atomic steps_wanted{}, steps_allowed{true}; struct Shared { std::mutex mutex; @@ -135,11 +135,13 @@ void on_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept { } if (skater_physics::available()) { const auto step = skater_physics::read_step(skater, seconds, wipeout); - if (steps_wanted.load(std::memory_order_acquire)) { + if (steps_wanted.load(std::memory_order_acquire) && steps_allowed.load(std::memory_order_acquire)) { const double at = std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); std::lock_guard lock(s.mutex); - if (s.steps.size() >= most_steps) s.steps.pop_front(); - s.steps.push_back({at, step}); + if (steps_allowed.load(std::memory_order_acquire)) { + if (s.steps.size() >= most_steps) s.steps.pop_front(); + s.steps.push_back({at, step, skater.entity, skater.rig}); + } } const bool ragdoll = step.ragdoll.value_or(false); if (ragdoll) { @@ -194,6 +196,12 @@ void want_steps() noexcept { want(); steps_wanted.store(true, std::memory_order_release); } +void before_level_transition(unsigned next) noexcept { + if (next == 3 || next == 14 || next == 22 || next == 24 || next == 25) { + steps_allowed.store(false, std::memory_order_release); + auto& s = shared(); std::lock_guard lock(s.mutex); s.steps.clear(); + } else if (next == 13 || next == 21) steps_allowed.store(true, std::memory_order_release); +} std::vector take_steps() { auto& s = shared(); std::lock_guard lock(s.mutex); diff --git a/Extension/Skater/skater_events.h b/Extension/Skater/skater_events.h index 1b7bc429..c5317530 100644 --- a/Extension/Skater/skater_events.h +++ b/Extension/Skater/skater_events.h @@ -39,11 +39,14 @@ struct Slam { struct StepNews { double at{}; skater_physics::Step step; + std::uintptr_t entity{}, rig{}; // ownership checked again before delivery }; // Client thread: starts reading the steps (once). void want() noexcept; // Client thread: from now on every step is kept for take_steps too (at most the last 512). void want_steps() noexcept; +// Invalidate queued geometry before scene teardown, including reloads of the same map. +void before_level_transition(unsigned next) noexcept; std::vector take_steps(); std::vector take_landings(); std::vector take_hits(); diff --git a/Extension/Skater/skater_physics.cpp b/Extension/Skater/skater_physics.cpp index 13d24b43..d358e815 100644 --- a/Extension/Skater/skater_physics.cpp +++ b/Extension/Skater/skater_physics.cpp @@ -53,6 +53,14 @@ bool read_contacts(const NoBailSkater& skater, skater_body::Contacts& result) no 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; }; + const auto point = field>(records, index, body::bone_point_offset); + const auto normal = field>(records, index, body::bone_normal_offset + side); + const auto finite3 = [](const auto& v) { + return std::all_of(v.begin(), v.begin() + 3, [](float x) { return std::isfinite(x) && std::abs(x) < 100000; }); + }; + if (b.touching && finite3(point)) b.point = Vec3{point[0], point[1], point[2]}; + const float norm = normal[0]*normal[0] + normal[1]*normal[1] + normal[2]*normal[2]; + if (b.touching && finite3(normal) && norm > .25f && norm < 2.25f) b.normal = Vec3{normal[0], normal[1], normal[2]}; 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)}; } @@ -122,6 +130,10 @@ bool start(std::uintptr_t base) noexcept { return false; } } + { + std::array actual{}; + if (!memory::peek(base + body::body_transform_contract.rva, actual) || actual != body::body_transform_contract.bytes) return false; + } if (!no_bail_available()) { logging::write(logging::Level::warning, logging::Channel::skater, "The skater's physics steps are unavailable: they come through No Bail's hooks, which did not start."); @@ -139,6 +151,7 @@ Step read_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept step.wipeout = wipeout; std::uint32_t physics_state{}; if (memory::peek(skater.context + state::physics_state_offset, physics_state)) { + step.state = 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; @@ -152,6 +165,27 @@ Step read_step(const NoBailSkater& skater, float seconds, bool wipeout) noexcept step.spin = game::length(spin); } } + std::uintptr_t physics{}, type{}, bodies{}; + std::uint32_t count{}; + if (memory::peek(skater.rig + state::rig_physics_offset, physics) && + memory::peek(physics, type) && type == skater.base + state::rig_physics_vtable && + memory::peek(physics + state::physics_bodies_offset, bodies) && memory::peek(bodies, count) && count == state::rig_body_count) { + for (std::size_t i = 1; i < skater_body::count; ++i) { + const auto at = bodies + i * state::physics_body_size; + std::uintptr_t owner{}; Step::Pose pose; + if (!memory::peek(at + state::body_owner_offset, owner) || owner != physics || + !memory::peek(at + body::body_transform_offset, pose.transform) || + !memory::peek(at + state::body_velocity_offset, pose.velocity) || !finite(pose.velocity)) continue; + bool valid = true; + for (float v : pose.transform) valid = valid && std::isfinite(v) && std::abs(v) < 100000; + for (unsigned a = 0; a < 3; ++a) for (unsigned b = 0; b <= a; ++b) { + float dot{}; + for (unsigned k = 0; k < 3; ++k) dot += pose.transform[a*4+k] * pose.transform[b*4+k]; + valid = valid && std::abs(dot - (a == b ? 1.0f : 0.0f)) < .05f; + } + if (valid) step.poses[i] = pose; + } + } (void)read_contacts(skater, step.body); return step; } diff --git a/Extension/Skater/skater_physics.h b/Extension/Skater/skater_physics.h index 02d53752..48d462b8 100644 --- a/Extension/Skater/skater_physics.h +++ b/Extension/Skater/skater_physics.h @@ -13,6 +13,7 @@ namespace dingosdk::skater_physics { // One physics step of the local skater. struct Step { float seconds{}; // how long the step simulated, in the game's time + std::optional state; // native selector state; no memory identity 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. @@ -22,6 +23,8 @@ struct Step { std::optional velocity; // How fast the pelvis turns, any way round, radians per second; empty when unknown. std::optional spin; + struct Pose { std::array transform{}; game::Vec3 velocity{}; }; + std::array, skater_body::count> poses; skater_body::Contacts body; // what each body touched in the step }; diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index 13def8ee..df584d2b 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -1,3 +1,4 @@ +#include "Extension/Rendering/plugin_effects.h" #include "runtime_internal.h" #include "Extension/Customization/developer_hoodie.h" #include "Extension/Customization/developer_board.h" @@ -1143,6 +1144,13 @@ void update_model(std::uintptr_t client, TickState& frame) { DINGO_PROFILE_ZONE("tick/update_model/network objects"); dingosdk::tick_network_objects(); } + { + bool loading{}; + { std::lock_guard lock(r.mutex); loading = r.requests.loading(); } + // Invalidate old-world handles before any plugin callback can use them. + dingosdk::effects::tick(r.base, client, !r.observer_failed && !loading && named_context_ready, + r.debug_model.noclip, r.debug_model.park_editor); + } if (r.observer_failed) return; // Keep the bounded restore/telemetry path available after catalog failure. { // Every tick, not at the 500 ms customization pace: the item grids follow the highlight. diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index eb0a70d5..66d84f2f 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -1,3 +1,5 @@ +#include "Extension/Rendering/plugin_effects.h" +#include "Extension/Skater/skater_events.h" #include "runtime_internal.h" #include "Extension/Customization/item_browser.h" #include "Extension/Rendering/Capture/capture_runtime.h" @@ -567,6 +569,8 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { } r.native_loading_logging = dingosdk::start_level_loading_logging(r.base, [](std::uintptr_t base, unsigned next) noexcept { + dingosdk::effects::before_level_transition(next); + dingosdk::skater_events::before_level_transition(next); dingosdk::local_profile_before_level_transition(next); dingosdk::world_entities::before_level_transition(base, next); dingosdk::multiplayer::native_menu_before_level_transition(base, next); diff --git a/STRUCTURE.md b/STRUCTURE.md index 877b4fe1..967a2e58 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -143,7 +143,7 @@ commands and a `Test/` folder. | `Plugins/` | the Luau plugin host and its `reskate.*` API (see below) | | `Profile/` | offline profile storage: skater, progress, missions, objects, preferences | | `Progression/` | challenges, entitlements, neighborhood unlocks, fast travel unlock, rip score, board wear | -| `Rendering/` | graphics controls and labels, display startup, replay export | +| `Rendering/` | graphics controls and labels, display startup, replay export, plugin particles and surface decals | | `ReplayKeyframes/` | replay keyframe curves and settings | | `Scripting/` | Lua startup, custom script loader, native blocking | | `Settings/` | gameplay/native/user settings, input bindings, engine tweaks, replay drafts | @@ -179,6 +179,9 @@ The Luau plugin host. A plugin is a folder in `Mods/` with a `reskate-plugin.jso - `plugin_api*.cpp` — the `reskate.*` API (game, world, UI, more); `plugin_game*`, `plugin_camera`, `plugin_joystick`. - `plugin_ui`, `plugin_sound`, `plugin_wave`, `plugin_http`, `plugin_session_feed`, `plugin_wire` — pages, audio, HTTP, multiplayer messages. - `plugin_commands` — the `plugin` console command. +- `plugin_effects`, `plugin_api_effects` — the permission, asset declarations and Lua calls for + [plugin particles and decals](contrib/plugin-effects.md). `Extension/Rendering/plugin_effects` + owns their native references, surface attachments and world lifetime. ## `Runtime/` diff --git a/cmake/Core.cmake b/cmake/Core.cmake index d4507b7d..d0362ebe 100644 --- a/cmake/Core.cmake +++ b/cmake/Core.cmake @@ -148,6 +148,8 @@ add_library(dingosdk_plugins STATIC Extension/Plugins/plugin_api_throwdowns.cpp Extension/Plugins/plugin_api_capture.cpp Extension/Plugins/plugin_content.cpp + Extension/Plugins/plugin_api_effects.cpp + Extension/Plugins/plugin_effects.cpp # The throwdown kit's store and the plugins' modes, which the plugin host and the game's # native UI share (kit::flag is the game's: Extension/Throwdowns/native_flag_session.cpp). Extension/Throwdowns/throwdown_kit.cpp diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index f4dddf6b..437b04b0 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -139,6 +139,7 @@ add_library(dingosdk_runtime SHARED Extension/Plugins/plugin_commands.cpp Extension/Plugins/plugin_http.cpp Extension/Plugins/plugin_game_runtime.cpp + Extension/Rendering/plugin_effects.cpp Extension/Skater/skater_animation.cpp Extension/Plugins/plugin_sound.cpp Extension/Skater/skater_events.cpp diff --git a/contrib/plugin-effects.md b/contrib/plugin-effects.md new file mode 100644 index 00000000..0bc52423 --- /dev/null +++ b/contrib/plugin-effects.md @@ -0,0 +1,88 @@ +# Plugin particles and decals + +The client `reskate.effects` API renders private mod assets. Declare the `effects` permission +and list each allowed asset in the manifest's `effects` array (at most 128 names): + +```json +{ + "api": 1, + "side": "client", + "entry": "plugin/main.lua", + "permissions": ["effects"], + "effects": ["effects/reskate/example/ebp_spark", "world/materials/decal/reskate_example/stain_dv"] +} +``` + +Particle names must start with `effects/reskate/`; decal names with +`world/materials/decal/reskate_`. Names are lowercase letters, digits, slashes, underscores +and hyphens, without empty path components. Stock/store asset names are refused. The native +bridge also checks the loaded asset class: `EffectBlueprint` for particles and +`EnvironmentDecalVolumeTemplateData` for decals. A declared name alone does not load content. +Ship compiled assets with the plugin and use the manifest's existing `resident` declarations +to keep their roots and shaders available on other maps. For example, a mod whose assets are +compiled into the common root bundle can add: + +```json +"resident": [{ + "bundle": "win32/levels/game/dingolevel_root/dingolevel_root", + "roots": ["effects/reskate/example/", "world/materials/decal/reskate_example/"], + "shaders": [{ + "name": "world/materials/decal/reskate_example/shaderblockdepot_splash", + "roots": ["world/materials/decal/reskate_example/"] + }] +}] +``` + +Use the bundle and shader depot names produced when compiling your own assets. The `effects` +allowlist contains exact names; `resident.roots` also accepts prefixes ending in `/`. These +declarations reuse the normal mod merge; they do not create assets or compile shaders at runtime. + +## Calls + +| Call | Result | +|---|---| +| `particle(asset, transform)` | Opaque numeric handle, or `nil, reason` when unavailable or full | +| `decal(asset, transform, {attach = false, lift = 0})` | Handle, or `nil, reason` | +| `move(handle, transform)` | Move a particle or an unattached decal | +| `stop(handle)` | Stop particle emission while existing particles finish | +| `opacity(handle, alpha)` | Set decal opacity, 0–1 | +| `release(handle)` | Stop and release one owned effect | +| `clear()` | Release all this plugin's effects; safe to repeat | +| `state()` | `ready, epoch`, describing the current native world | + +A transform is 16 numbers: three world-space basis vectors with a zero fourth component, +then world position with a fourth component of one. The axes must be orthogonal and have +length .001–20 metres; positions must be within ±100,000 metres. Particle emission points +along its Y axis. Decal Y gives projection depth, X/Z its footprint. A decal with `attach=true` +queries collision around its center, along Y; `lift` (0–1 metre) is the center's offset from +the contact surface. A missed query returns nil. Bound decals follow the receiver's rigid +pose and disappear when its generation-bearing physics handle becomes invalid. + +Handles belong to one plugin and are never native addresses. Bad arguments, undeclared assets +and foreign/released handles raise Lua errors. Each plugin can hold 24 particles and 2,048 +decals and make 8,192 effect calls per tick. The native service caps all plugins together at +64 particles, 4,096 decals and 512 cached asset names. Native effects also expire after ten +minutes of real time as a cleanup limit; plugins should release them sooner. + +Rendering is unavailable without a local skater, during loading, replay, Noclip or park +editing, or when any required native byte check fails. Check `state()` before rendering and +call `clear()` when its epoch changes, then discard local handles and simulation history. +World resets automatically release native references. `release()` remains safe for an owned +handle that expired natively, but a second release is an argument error. Unload, disable and +VM failure release all effects. Effects are local and do not replicate. +The dedicated server has no renderer: `state()` returns false and creation returns nil. + +## Physics geometry + +With `events.physics`, `physics_step` keeps its existing timing/contact fields and adds: + +- `state`: optional local physics selector state. +- `bodies[name].point` and `.normal`: optional world-space contact geometry. The normal + accompanies the selected peak impact speed; unknown geometry is absent, not zero. +- `poses[name].transform` and `.velocity`: optional world-space rigid body matrix (16 numbers) + and linear velocity (three numbers, metres/second), including airborne bodies. + +These values are copied on the physics thread at the existing contact/skeleton boundary. +Queued samples from another local entity/rig or a level transition are discarded. Missing +fields mean unavailable data; do not synthesize a collision from them. The event's `at` is +in the same milliseconds clock as `reskate.time.now()` and `seconds` is simulation time.