mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-28 06:46:52 +01:00
Plugins: render private particles and decals with physics geometry
Add permission-gated effect handles, native surface attachments, and world lifetime cleanup. Extend physics_step with optional contact geometry and body poses. Document the generic API and validate it with self-contained plugin and geometry fixtures.
This commit is contained in:
1 parent
788eb98352
commit
4f07b448a3
39 files changed
+1518
-11
No files matched your search
@@ -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}")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
#include "Engine/Game/Build/fingerprint.h"
|
||||
#include <utility>
|
||||
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<Fingerprint,3> 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<Fingerprint,3> 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<Fingerprint,16> 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<std::pair<unsigned,std::uintptr_t>,9> property_cleanup{{
|
||||
{0x58,0x3dc5e90},
|
||||
{0x50,0x1986260},
|
||||
{0x40,0xc89ce0},
|
||||
{0x38,0x1586050},
|
||||
{0x30,0xc89ce0},
|
||||
{0x28,0x3dc5e90},
|
||||
{0x20,0x1355e50},
|
||||
{0x18,0xadd970},
|
||||
{0x10,0x2aab10},
|
||||
}};
|
||||
}
|
||||
@@ -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<Fingerprint,4> 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}},
|
||||
}};
|
||||
}
|
||||
@@ -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<std::uintptr_t, 4> service_methods{0x10, 0x20, 0x28, 0x30};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
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<class Read,class Write> 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<class Read,class Write> 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<class Read,class Write> 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<CountSettingSample> original_,applied_;
|
||||
bool yielded_{};
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
|
||||
// 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<Vec3> point, normal; // hardest contact, world space; absent if unreadable
|
||||
};
|
||||
// The whole body in one physics step.
|
||||
struct Contacts {
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
|
||||
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<float,4> 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<float,4> params{};
|
||||
std::array<std::byte,56> 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<std::byte,9> 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<std::uint32_t>(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<class F> F function(std::uintptr_t base, std::uintptr_t rva) {return reinterpret_cast<F>(base+rva);}
|
||||
inline void destroy_properties(std::uintptr_t base,Properties& props) {
|
||||
auto* bytes=reinterpret_cast<std::byte*>(&props);
|
||||
for (const auto& item:build::property_cleanup)
|
||||
function<void(*)(void*)>(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<unsigned char,32> 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<void(*)(void*)>(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<void(*)(std::uintptr_t,std::uint32_t,const Properties*)>(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<unsigned char,32> 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<unsigned char,32> 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<float,16>& transform) {
|
||||
if (!id) return;
|
||||
alignas(16) const auto matrix=transform;
|
||||
function<void(*)(std::uintptr_t,std::uint32_t,const float*)>(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<float,16>& transform,std::optional<ReceiverMask> 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<void(*)(void*)>(base,build::settings_init)(&settings);
|
||||
settings.culling={100,100,100,100}; settings.flags|=3;
|
||||
Properties props{};
|
||||
function<void(*)(void*)>(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<void(*)(void*)>(base,build::resource_release)(&s.resource);
|
||||
}
|
||||
} cleanup{base,props,settings};
|
||||
assign_material(props,material,function<ReferenceAssign>(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<Handle*(*)(std::uintptr_t,Handle*,const Settings*,const Properties*,const Handle*,
|
||||
const float*,bool)>(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<void(*)(void*)>(base,build::release)(&handle);
|
||||
handle={};
|
||||
}
|
||||
}
|
||||
@@ -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<std::uint64_t, Effect> 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<bool, std::uint64_t> effects_state() override { return {effects_ready, effect_epoch}; }
|
||||
std::optional<std::uint32_t> state;
|
||||
std::uint64_t wipe{};
|
||||
std::vector<std::string> calls;
|
||||
@@ -622,7 +646,7 @@ struct FakeGame final : Game {
|
||||
return {};
|
||||
}
|
||||
std::vector<std::pair<std::string, std::string>> 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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
#include "plugin_api.h"
|
||||
#include "plugin_effects.h"
|
||||
#include "lualib.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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<float>(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<std::uint64_t>(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<float>(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<double>(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<float>(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<double>(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}});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#include "plugin_effects.h"
|
||||
#include <cmath>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace dingosdk::plugins {
|
||||
using EffectMatrix = std::array<float, 16>;
|
||||
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;
|
||||
}
|
||||
@@ -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<Fall> 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<Vec3> point, normal;
|
||||
};
|
||||
std::vector<Body> bodies;
|
||||
struct Pose { EffectMatrix transform{}; Vec3 velocity{}; };
|
||||
std::map<std::string, Pose, std::less<>> poses; // all available local bodies, including airborne ones
|
||||
std::optional<std::uint32_t> 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<bool, std::uint64_t> 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.
|
||||
|
||||
@@ -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<bool, std::uint64_t> 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<PhysicsStep> take_physics_steps() override {
|
||||
skater_events::want_steps();
|
||||
std::vector<PhysicsStep> 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), {});
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "plugin_manifest.h"
|
||||
#include "plugin_effects.h"
|
||||
#include "Engine/Core/Json/json.h"
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
@@ -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<std::string_view, 49> permissions{
|
||||
constexpr std::array<std::string_view, 50> 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");
|
||||
|
||||
@@ -22,6 +22,7 @@ struct Manifest {
|
||||
std::set<std::string, std::less<>> permissions;
|
||||
// net.http: the exact host names its web requests may go to ("api.example.com"), lowercase.
|
||||
std::vector<std::string> domains;
|
||||
std::set<std::string, std::less<>> effects; // exact private particle/material asset names
|
||||
// mods.read: the folders it reads in every enabled mod ("parks": <mod>/parks/...), lowercase
|
||||
// (reskate.mods.content and read_content). Other mods ship data for it there, with no code.
|
||||
std::vector<std::string> content;
|
||||
|
||||
@@ -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<int> due;
|
||||
for (const auto& [id, timer] : timers_)
|
||||
|
||||
@@ -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<Subcommand> subcommands; };
|
||||
struct Timer { double due{}, every{}; int function{}; };
|
||||
@@ -325,6 +326,8 @@ private:
|
||||
std::vector<double> errors_;
|
||||
double log_second_{};
|
||||
int log_lines_{};
|
||||
std::map<std::uint64_t, bool> effect_handles_; // bool: decal
|
||||
int effect_calls_{};
|
||||
int raycasts_{}; // since the last tick
|
||||
std::optional<float> touch_reach_;
|
||||
std::map<std::uint64_t, Contact> contacts_;
|
||||
|
||||
@@ -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 <cmath>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
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<class T> void put(std::vector<std::byte>& 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<std::byte>& bytes) { return reinterpret_cast<std::uintptr_t>(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<std::uintptr_t>(&record);
|
||||
std::vector<std::byte> asset(32);
|
||||
put(asset, addr::effects::asset_type, reinterpret_cast<std::uintptr_t>(&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<std::byte> 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<float,4>{1,0,0,0});
|
||||
put(contacts, record+b::bone_normal_offset+b::bone_tracked_offset, std::array<float,4>{0,1,0,0});
|
||||
put(contacts, record+b::bone_point_offset, std::array<float,4>{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<float,4>{std::numeric_limits<float>::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<CountSettingSample> { 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
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<unsigned char,32> 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<names.size();++i) {
|
||||
const auto read=[&] {return sample(base,names[i]);};
|
||||
const auto write=[&](const CountSettingSample& expected,std::uint32_t value) {
|
||||
return assign(base,names[i],expected,value);
|
||||
};
|
||||
ready=leases_[i].update(extra,read,write) && ready;
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
private:
|
||||
inline static constexpr std::array names={
|
||||
"WorldRender.MaxDecalVolumeCount",
|
||||
"WorldRender.EnvironmentDecalVolumeMaxCount.Low",
|
||||
"WorldRender.EnvironmentDecalVolumeMaxCount.Medium",
|
||||
"WorldRender.EnvironmentDecalVolumeMaxCount.High",
|
||||
"WorldRender.EnvironmentDecalVolumeMaxCount.Ultra"};
|
||||
static std::optional<CountSettingSample> 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<Getter>(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<Setter>(base+contract::named_settings_set)(manager,name,0,&value);}
|
||||
__except(EXCEPTION_EXECUTE_HANDLER) {return false;}
|
||||
}
|
||||
std::array<TransientCountLease,names.size()> leases_;
|
||||
std::uintptr_t base_{};
|
||||
bool checked_{},valid_{};
|
||||
};
|
||||
}
|
||||
@@ -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 <unordered_map>
|
||||
|
||||
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<std::uint64_t,std::optional<Matrix>> poses_;
|
||||
template<class F> F function(std::uintptr_t rva) const {return reinterpret_cast<F>(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<unsigned char,32> 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<bool(*)(const void*)>(build::contracts[1].rva)(body.data())) {
|
||||
alignas(16) std::array<float,4> position{},rotation{};
|
||||
function<float*(*)(const void*,float*)>(build::body_position)(body.data(),position.data());
|
||||
function<float*(*)(const void*,float*)>(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<void(*)(std::uintptr_t,void*)>(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<result.count;++n) {
|
||||
float fraction{};
|
||||
if (!memory::peek(fractions+std::uintptr_t(result.first+n)*sizeof(float),fraction)) return false;
|
||||
if (std::isfinite(fraction) && fraction>=0 && fraction<=1 && fraction<closest) {
|
||||
closest=fraction; index=result.first+n;
|
||||
}
|
||||
}
|
||||
if (closest>1) return false;
|
||||
alignas(16) const std::array<std::uint64_t,3> hit{world_,index,result.data};
|
||||
alignas(16) ReceiverId body{};
|
||||
function<void*(*)(const void*,void*)>(receiver_build::contracts[0].rva)(hit.data(),body.data());
|
||||
if (!pose(body,receiver.pose)) return false;
|
||||
receiver.id=body; return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
#include "Extension/Plugins/plugin_effects.h"
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
namespace dingosdk::effects {
|
||||
using Matrix = plugins::EffectMatrix;
|
||||
using ReceiverId = std::array<std::uint64_t, 2>;
|
||||
struct Receiver {ReceiverId id{}; Matrix pose{};};
|
||||
}
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
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<float,4>& rotation,const std::array<float,4>& 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);
|
||||
}
|
||||
}
|
||||
@@ -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 <Windows.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
|
||||
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<std::uint64_t, Entry> entries;
|
||||
std::map<std::string, std::pair<std::uintptr_t, std::uint64_t>, 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<bool> suspended{true};
|
||||
bool checked{}, valid{}, allowed{};
|
||||
};
|
||||
State& state() { static auto* s = new State; return *s; }
|
||||
template<class F> F function(const State& s, std::uintptr_t rva) { return reinterpret_cast<F>(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<void(*)(std::uintptr_t*, bool, float)>(s, fx::stop.rva)(&e.particle, true, 0);
|
||||
function<void(*)(std::uintptr_t*)>(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<unsigned char, 32> 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<std::byte, fx::descriptor_size> descriptor{};
|
||||
alignas(16) const auto transform = r.transform;
|
||||
function<void(*)(void*, std::uintptr_t, const float*)>(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<void(*)(std::uintptr_t, std::uintptr_t*, void*)>(s, fx::create.rva)(0, &e.particle, descriptor.data());
|
||||
function<void(*)(void*)>(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<void(*)(std::uintptr_t*, const float*)>(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<void(*)(std::uintptr_t*, bool, float)>(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<bool, std::uint64_t> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
#include <utility>
|
||||
#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<bool, std::uint64_t> status();
|
||||
}
|
||||
@@ -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<bool> steps_wanted{};
|
||||
std::atomic<bool> 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<double, std::milli>(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<StepNews> take_steps() {
|
||||
auto& s = shared();
|
||||
std::lock_guard lock(s.mutex);
|
||||
|
||||
@@ -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<StepNews> take_steps();
|
||||
std::vector<Landing> take_landings();
|
||||
std::vector<BodyHit> take_hits();
|
||||
|
||||
@@ -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<std::array<float, 4>>(records, index, body::bone_slide_offset + side));
|
||||
const auto hit = [&](std::uintptr_t offset) { return field<std::uint8_t>(records, index, offset) != 0; };
|
||||
const auto point = field<std::array<float, 4>>(records, index, body::bone_point_offset);
|
||||
const auto normal = field<std::array<float, 4>>(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<unsigned char, 32> 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;
|
||||
}
|
||||
|
||||
@@ -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<std::uint32_t> 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<game::Vec3> velocity;
|
||||
// How fast the pelvis turns, any way round, radians per second; empty when unknown.
|
||||
std::optional<float> spin;
|
||||
struct Pose { std::array<float, 16> transform{}; game::Vec3 velocity{}; };
|
||||
std::array<std::optional<Pose>, skater_body::count> poses;
|
||||
skater_body::Contacts body; // what each body touched in the step
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+4
-1
@@ -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/`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user