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:
gbgyo committed 2026-10-25 10:56:59 +03:00
1 parent 788eb98352
commit 4f07b448a3
39 files changed
+1518 -11

No files matched your search

+5
View File
@@ -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}")
+66
View File
@@ -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}},
}};
}
+26
View File
@@ -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;
}
+6
View File
@@ -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_{};
};
}
+2
View File
@@ -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 {
+131
View File
@@ -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={};
}
}
+229 -1
View File
@@ -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);
+1
View File
@@ -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");
+1
View File
@@ -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
+99
View File
@@ -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}});
}
}
+29
View File
@@ -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;
}
}
+18
View File
@@ -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;
}
+15 -1
View File
@@ -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.
+15 -2
View File
@@ -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), {});
+12
View File
@@ -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"))
+13 -2
View File
@@ -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");
+1
View File
@@ -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;
+1
View File
@@ -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_)
+3
View File
@@ -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; }
}
+17
View File
@@ -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);
}
}
+69
View File
@@ -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_{};
};
}
+89
View File
@@ -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;
}
};
}
+55
View File
@@ -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);
}
}
+227
View File
@@ -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);
}
}
+16
View File
@@ -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();
}
+12 -4
View File
@@ -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);
+3
View File
@@ -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();
+34
View File
@@ -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;
}
+3
View File
@@ -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
};
+8
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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/`
+2
View File
@@ -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
+1
View File
@@ -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
+88
View File
@@ -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.