Plugins: prop hunt APIs, and plugin commands in the console lookup

- reskate.entities.blueprints lists the game's object blueprints (meshes only if asked),
  and reskate.entities.bounds gives a blueprint's box, so a mod can pick a prop by list or at random
- Attached objects take upright, turn and ground (a floor found within reach below), the "root"
  joint, and a 6th options argument with solid; spawned props' bodies are found and ghosted
- reskate.skater.hide, reskate.world.pick, and skater.get gives right and switch
- reskate.console.command takes an optional 4th table of subcommands; the console lookup
  suggests plugin commands word by word, and plugin commands lists the subcommands
- objbody: a test command for finding which body setter takes a prop out of collision
This commit is contained in:
AyeZeeBB committed 2026-10-22 00:30:06 -05:00
1 parent c35e9a357b
commit 45346b122c
26 files changed
+1238 -36

No files matched your search

@@ -0,0 +1,29 @@
#pragma once
#include <array>
#include <cstdint>
namespace dingosdk::game::build::v20260929::physics_bodies {
// Supported SHA-256 fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9.
// The client physics world's bodies, as the park editor's surface probe and the game's ghosting
// of placed objects use them (read 2026-10-21). A body is a handle {world, index | generation << 32}
// (park_editor_surface.h's SurfaceBody); the world's store is at world + 0x28. In the store:
// +0x1008 u32 per body: its owner's index. +0x8b0: a bit per owner, clear for an ECS entity;
// +0x8b8: a u64 per owner, the entity id for an ECS owner (body_entity, 0x3d09a20).
// +0xf68 a bit per body: a ghost, which skaters go through (the Build Kit's walk-through
// objects, 0x708de0 -> 0x25d3610 for each body of the object).
// ghost_body(const body*) sets a body's ghost bit and tells the world when it changed;
// solid_body(const body*) clears it (0x708be0 -> 0x25d3170). The game's ghosting also sets the
// object's main body's bit at +0x9f8 (main_ghost 0x25d3360, which tells the world too) and its
// solid side clears it (main_solid 0x25d2e90). Client thread.
inline constexpr std::uintptr_t store_offset = 0x28;
inline constexpr std::uintptr_t body_owner_offset = 0x1008, owner_kind_offset = 0x8b0, owner_value_offset = 0x8b8;
inline constexpr std::uintptr_t ghost_body = 0x25d3610, solid_body = 0x25d3170, main_ghost = 0x25d3360, main_solid = 0x25d2e90;
inline constexpr std::array<unsigned char, 24> main_ghost_prefix{
0x40,0x53,0x48,0x83,0xec,0x20,0x44,0x8b,0x49,0x08,0x48,0x8b,0xd9,0x41,0x8b,0xc9,0x41,0xba,0x01,0x00,0x00,0x00,0x41,0xd3};
inline constexpr std::array<unsigned char, 24> main_solid_prefix{
0x48,0x8b,0x01,0xba,0x01,0x00,0x00,0x00,0x44,0x8b,0x41,0x08,0x41,0x8b,0xc8,0xd3,0xe2,0x48,0x8b,0x48,0x28,0xf7,0xd2,0x49};
inline constexpr std::array<unsigned char, 24> ghost_body_prefix{
0x44,0x8b,0x49,0x08,0x4c,0x8b,0xd9,0x41,0x8b,0xc9,0x41,0xba,0x01,0x00,0x00,0x00,0x41,0xd3,0xe2,0x49,0xc1,0xe9,0x05,0x49};
inline constexpr std::array<unsigned char, 24> solid_body_prefix{
0x44,0x8b,0x41,0x08,0x4c,0x8b,0xd9,0x41,0x8b,0xc8,0x41,0xba,0x01,0x00,0x00,0x00,0x41,0xd3,0xe2,0x49,0xc1,0xe8,0x05,0x45};
}
@@ -10,6 +10,19 @@ inline constexpr std::uintptr_t blueprint_type = 0x7586ce8;
// ObjectBlueprint ClassInfo: a world object's blueprint (props, checkpoints), a different branch
// of the type hierarchy from the skater Blueprint above.
inline constexpr std::uintptr_t object_blueprint_type = 0x75043a8;
// A world object blueprint's mesh and its bounds, read in a running game on 2026-10-21 (a trash bag
// 0.66 x 0.58 x 0.62 m, a park bench 3.08 x 0.86 x 0.71 m, a traffic cone 0.32 x 0.60 x 0.32 m,
// each with its origin at its base): the blueprint's object data right after it (blueprint + 0x80,
// also its +0x68), whose mesh asset is at +0xd8; the mesh asset's loaded mesh at +0x70, and that
// mesh's box at +0x28 -> {min float[4], max float[4]}, in the object's own frame. A blueprint
// without a mesh there (a widget, a logic object) shows nothing when it is made.
inline constexpr std::uintptr_t object_data_offset = 0x80; // blueprint
inline constexpr std::uintptr_t object_data_vtable = 0x64062a0;
inline constexpr std::uintptr_t object_data_mesh_offset = 0xd8; // object data -> mesh asset
inline constexpr std::uintptr_t mesh_asset_vtable = 0x64e6f30;
inline constexpr std::uintptr_t mesh_asset_loaded_offset = 0x70; // mesh asset -> loaded mesh
inline constexpr std::uintptr_t loaded_mesh_vtable = 0x64f2ba8;
inline constexpr std::uintptr_t loaded_mesh_bounds_offset = 0x28; // loaded mesh -> {min, max}
// Secondary vtables of the skater animation component (engine::skater_component_vtable) at +0x38 and +0x40.
inline constexpr std::array<std::uintptr_t, 2> skater_component_base_vtables{0x61705e8, 0x6170650};
// ClientSkaterSource::applyAppearance. It enables the customization component and requests
@@ -1,6 +1,9 @@
#pragma once
#include "Engine/Game/World/park_editor.h"
#include "Extension/Objects/object_placements.h"
#include <array>
#include <optional>
#include <string>
namespace dingosdk {
ParkEditorModel local_park_editor();
@@ -32,6 +35,18 @@ bool queue_local_park_paste(const EditorPasteRequest &);
bool queue_local_park_object_set(const EditorObjectSetRequest &);
bool queue_local_park_delete(const EditorDeleteRequest &);
void tick_local_park_editor() noexcept;
// The world raycast, and the object it meets (reskate.world.pick; world_pick.cpp): where, its root
// entity (0 for none), its pose as the placements runtime reads it (scale xyz, -, rotation xyzw,
// position xyz, -), and the world object blueprint it was made from, when that is known.
struct WorldPick {
std::array<float, 3> at{};
std::uint64_t entity{};
std::optional<std::array<float, 12>> pose;
std::string blueprint;
};
std::optional<WorldPick> local_world_pick(const std::array<float, 3> &from, const std::array<float, 3> &to);
// Gives world_entities the physics world (its objects made not solid, and those standing on the floor).
void register_world_physics() noexcept;
struct WorldRayHit {
bool cast{}, hit{};
std::array<float, 3> at{};
+427
View File
@@ -0,0 +1,427 @@
#include "park_editor_internal.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Game/Abi/native_data.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/engine.h"
#include "Engine/Game/Build/20260929/physics_bodies.h"
#include "Engine/Game/Build/20260929/skater_entities.h"
#include "Extension/Objects/local_placements_runtime.h"
#include "Extension/World/world_entities.h"
#include "Extension/World/world_entities_contract.h"
#include <atomic>
#include <cmath>
#include <format>
#include <mutex>
// reskate.world.pick: the world raycast, and the object it meets. The ray is the park editor's
// surface probe (park_editor_surface.h); the hit's body gives its entity as the editor's picking
// does (park_editor_selection.cpp), resolved to its root, whose pose the placements runtime reads.
//
// Which blueprint the root entity was made from is looked for in the entity's query record (its
// pointers, and theirs one level down): a world object blueprint (its type chain is
// ObjectBlueprint) or the root data a blueprint keeps right after itself (blueprint + 0x68 -> data):
// 0x80 on for a world object blueprint (read in a running game on 2026-10-21: all 152 World/Props
// blueprints, their data's type at game + 0x64062a0), 0x70 for the skateboard's (native_pose_layout.h). It is taken only when its name, looked up the way the
// game looks names up, gives that same asset back. Where it was found is logged once.
namespace dingosdk {
using namespace profile_runtime;
using namespace profile_runtime::park_editor_detail;
namespace {
std::uintptr_t pointer_at(std::uintptr_t at) noexcept {
std::uintptr_t value{};
return memory::read(at, value) ? value : 0;
}
// Any entity's root, for the ray's owner: nothing ignored, every hit counted. The body's own entity
// of the last hit is kept for the log.
// What the last hit gave, step by step, for the log when it gives no entity. A body is {world,
// index | generation << 32}; the world's store (world + 0x28) maps a body index to its owner
// (+0x1008, u32 each), an owner is an ECS entity when its bit in +0x8b0 is clear, and +0x8b8 keeps
// each owner's u64: the entity id for an ECS owner, something else for the others (the game's body
// owner functions 0x25d5fc0, 0x25d6fb0, 0x25d7040; body_entity 0x3d09a20 takes only ECS owners).
struct HitSteps {
bool body{};
std::uint64_t entity{}, root{};
bool resolved{};
std::uintptr_t world{}, store{};
std::uint32_t index{}, owner{};
bool ecs{};
std::uint64_t value{};
};
HitSteps last_hit{};
std::uint64_t any_root(std::uintptr_t world, std::uint32_t index, std::uintptr_t data) {
const auto &api = editor_state().picking_api;
alignas(16) editor::SurfaceBody body{};
alignas(16) std::array<std::uint64_t, 2> entity{}, root{};
last_hit = {};
if (!surface_body(world, index, data, body)) return 0;
last_hit.body = true;
last_hit.world = static_cast<std::uintptr_t>(body[0]);
last_hit.index = static_cast<std::uint32_t>(body[1]);
last_hit.store = pointer_at(last_hit.world + 0x28);
if (last_hit.store) {
std::uint32_t owner{};
std::uint8_t bits{};
if (memory::read(pointer_at(last_hit.store + 0x1008) + std::uintptr_t{last_hit.index} * 4, owner) &&
memory::read(pointer_at(last_hit.store + 0x8b0) + (owner >> 3), bits)) {
last_hit.owner = owner;
last_hit.ecs = ((bits >> (owner & 7)) & 1) == 0;
last_hit.value = pointer_at(pointer_at(last_hit.store + 0x8b8) + std::uintptr_t{owner} * 8);
}
}
api.body_entity(entity.data(), body.data());
if (!entity[0]) return 0;
last_hit.entity = entity[0];
auto &r = placements_runtime();
// An entity the placements runtime does not resolve is still the hit's: its own id stands.
if (r.resolve(root.data(), entity[0], false) != root.data() || !r.valid(root.data())) return entity[0];
last_hit.root = root[0];
last_hit.resolved = true;
return root[0];
}
std::string asset_name(std::uintptr_t asset) {
const auto at = pointer_at(asset + 0x18);
if (!at) return {};
std::string name;
for (std::size_t i = 0; i < 256; ++i) {
char c{};
if (!memory::read(at + i, c) || !c) break;
if (static_cast<unsigned char>(c) < 0x20 || static_cast<unsigned char>(c) > 0x7e) return {};
name.push_back(c);
}
return name.size() < 256 ? name : std::string{};
}
// A world object blueprint whose name looks up to it in a loaded domain.
std::string confirmed_blueprint(std::uintptr_t base, std::uintptr_t asset) {
const auto read = [](std::uintptr_t at) { return pointer_at(at); };
if (!asset || !world_entities::detail::object_blueprint(asset, base, read)) return {};
auto name = asset_name(asset);
const auto find = game::native_data().find_asset;
if (name.empty() || !find) return {};
for (std::uint16_t domain = 0; domain < 0xbbf; ++domain)
if (pointer_at(base + addr::engine::domain_owners + std::uintptr_t(domain) * 8) && find(domain, name.c_str()) == asset) return name;
return {};
}
// `value` names a blueprint: the asset itself, or the root data it keeps (blueprint + 0x68 -> data,
// the blueprint 0x80 or 0x70 before it).
std::string blueprint_of(std::uintptr_t base, std::uintptr_t value) {
const auto asset = value & ~std::uintptr_t{7};
if (asset < 0x10000) return {};
if (auto name = confirmed_blueprint(base, asset); !name.empty()) return name;
for (const std::uintptr_t before : {0x80u, 0x70u}) {
const auto header = asset - before;
if ((pointer_at(header + 0x68) & ~std::uintptr_t{4}) == asset) return confirmed_blueprint(base, header);
}
return {};
}
std::atomic<bool> path_logged{};
// A world object's blueprint from a pointer of its physics owner: its object data (the world object
// data type, engine + object_data_vtable) found within two levels, the blueprint 0x80 before it.
std::atomic<unsigned> classic_logged{};
std::string classic_blueprint(std::uintptr_t base, std::uintptr_t start) {
namespace e = addr::skater_entities;
const auto data_type = base + e::object_data_vtable;
const auto named = [&](std::uintptr_t data, const std::string &path) -> std::string {
if (pointer_at(data) != data_type) return {};
auto name = confirmed_blueprint(base, data - e::object_data_offset);
if (!name.empty() && classic_logged.fetch_add(1) < 4)
logging::log(logging::Level::info, logging::Channel::level, "World pick: a physics owner's object data at {} ({}).", path, name);
return name;
};
if (auto name = named(start, "the owner"); !name.empty()) return name;
for (std::uintptr_t offset = 0; offset < 0x200; offset += 8) {
const auto value = pointer_at(start + offset);
if (value < 0x10000 || value > 0x7ff000000000) continue;
if (auto name = named(value, std::format("+0x{:x}", offset)); !name.empty()) return name;
for (std::uintptr_t inner = 0; inner < 0x100; inner += 8) {
const auto deeper = pointer_at(value + inner);
if (deeper < 0x10000 || deeper > 0x7ff000000000) continue;
if (auto name = named(deeper, std::format("+0x{:x} -> +0x{:x}", offset, inner)); !name.empty()) return name;
}
}
return {};
}
// The pose's numbers: scale xyz, rotation xyzw, position xyz (the other two lanes are padding).
constexpr std::array<int, 10> numeric_lanes{0, 1, 2, 4, 5, 6, 7, 8, 9, 10};
std::string entity_blueprint(std::uintptr_t base, std::uint64_t root) {
auto &r = placements_runtime();
alignas(16) std::array<std::uint64_t, 2> reference{};
alignas(16) std::array<std::uint64_t, 3> query{};
if (r.resolve(reference.data(), root, false) != reference.data() || !r.valid(reference.data()) ||
r.query(query.data(), root) != query.data())
return {};
const std::array<std::pair<const char *, std::uintptr_t>, 4> starts{
{{"reference[1]", reference[1]}, {"query[0]", query[0]}, {"query[1]", query[1]}, {"query[2]", query[2]}}};
for (const auto &[label, start] : starts) {
if (start < 0x10000) continue;
for (std::uintptr_t offset = 0; offset < 0x200; offset += 8) {
const auto value = pointer_at(start + offset);
if (value < 0x10000) continue;
if (auto name = blueprint_of(base, value); !name.empty()) {
if (!path_logged.exchange(true))
logging::log(logging::Level::info, logging::Channel::level, "World pick: a blueprint at {} + 0x{:x} ({}).", label, offset, name);
return name;
}
for (std::uintptr_t inner = 0; inner < 0x100; inner += 8) {
const auto deeper = pointer_at(value + inner);
if (deeper < 0x10000) continue;
if (auto name = blueprint_of(base, deeper); !name.empty()) {
if (!path_logged.exchange(true))
logging::log(logging::Level::info, logging::Channel::level, "World pick: a blueprint at {} + 0x{:x} -> + 0x{:x} ({}).", label,
offset, inner, name);
return name;
}
}
}
}
return {};
}
} // namespace
// Which plugin object (world_entities.h) a hit's entity ids belong to: each object's native entity
// is searched (0x400 bytes, and 0x200 of every pointer in them) for the hit's raw or root id. Logged,
// to learn where a world object entity keeps its ECS id (for a plugin object made not solid).
std::atomic<unsigned> ids_logged{};
void log_plugin_object_ids(std::uint64_t raw, std::uint64_t root) {
if (ids_logged.load() >= 6) return;
const auto entities = world_entities::live_entities();
for (const auto entity : entities) {
for (std::uintptr_t offset = 0; offset < 0x400; offset += 8) {
const auto value = pointer_at(entity + offset);
if (value && (value == raw || value == root)) {
++ids_logged;
logging::log(logging::Level::info, logging::Channel::level, "World pick: plugin object {:#x} keeps the hit's {} id {:#x} at +0x{:x}.",
entity, value == root ? "root" : "body", value, offset);
return;
}
if (value < 0x10000 || value > 0x7ff000000000) continue;
for (std::uintptr_t inner = 0; inner < 0x200; inner += 8) {
const auto deeper = pointer_at(value + inner);
if (deeper && (deeper == raw || deeper == root)) {
++ids_logged;
logging::log(logging::Level::info, logging::Channel::level,
"World pick: plugin object {:#x} keeps the hit's {} id {:#x} at +0x{:x} -> +0x{:x}.", entity,
deeper == root ? "root" : "body", deeper, offset, inner);
return;
}
}
}
}
++ids_logged;
logging::log(logging::Level::info, logging::Channel::level, "World pick: the hit's ids {:#x} (body) and {:#x} (root) are in none of {} plugin objects.", raw,
root, entities.size());
}
std::optional<WorldPick> local_world_pick(const std::array<float, 3> &from, const std::array<float, 3> &to) {
std::lock_guard lock(local_runtime().native_mutex);
auto &r = placements_runtime();
auto &e = editor_state();
if (!local_runtime().active || !r.context || !e.surface_api.ready || !e.picking_api.ready) return std::nullopt;
for (const auto v : {from[0], from[1], from[2], to[0], to[1], to[2]})
if (!std::isfinite(v) || std::abs(v) > 1e5f) return std::nullopt;
std::array<std::uintptr_t, 2> context{};
r.context(context.data());
if (!context[0]) return std::nullopt;
const auto world = e.surface_api.world(context[0]);
if (!world) return std::nullopt;
std::uint64_t root{};
const auto hit = editor::cast_surface(e.surface_api, world, from, to, &any_root, {}, &root);
if (!hit) return std::nullopt;
WorldPick pick;
pick.at = *hit;
pick.entity = root;
static std::atomic<unsigned> steps_logged{};
if (steps_logged.fetch_add(1) < 8)
logging::log(logging::Level::info, logging::Channel::level,
"World pick: hit at ({:.2f}, {:.2f}, {:.2f}); body {}, entity {:#x}, root {:#x}{}; world {:#x} store {:#x} body {} owner {} {} {:#x}.",
pick.at[0], pick.at[1], pick.at[2], last_hit.body ? "read" : "not read", last_hit.entity, last_hit.root,
last_hit.resolved ? "" : " (not resolved)", last_hit.world, last_hit.store, last_hit.index, last_hit.owner,
last_hit.ecs ? "ecs" : "other", last_hit.value);
// An owner that is not an ECS entity: the object data it leads to names the blueprint.
if (!last_hit.ecs && last_hit.value > 0x10000 && pick.blueprint.empty()) {
try {
pick.blueprint = classic_blueprint(local_runtime().base, static_cast<std::uintptr_t>(last_hit.value));
} catch (...) {
}
}
if (root) {
try {
log_plugin_object_ids(last_hit.entity, root);
} catch (...) {
}
alignas(16) std::array<std::uint64_t, 2> reference{};
alignas(16) std::array<std::uint64_t, 3> query{};
alignas(16) std::array<float, 12> pose{};
if (r.resolve(reference.data(), root, false) == reference.data() && r.valid(reference.data()) &&
r.query(query.data(), root) == query.data() && r.pose(pose.data(), query.data()) == pose.data() &&
std::all_of(numeric_lanes.begin(), numeric_lanes.end(), [&](int index) { return std::isfinite(pose[index]); }))
pick.pose = pose;
try {
pick.blueprint = entity_blueprint(local_runtime().base, root);
} catch (...) {
}
}
return pick;
}
}
// ---- the physics world, for world_entities' objects that are not solid or stand on the floor ------
namespace dingosdk {
namespace {
namespace pb = addr::physics_bodies;
std::uintptr_t physics_world() noexcept {
try {
std::lock_guard lock(local_runtime().native_mutex);
auto &r = placements_runtime();
auto &e = editor_state();
if (!local_runtime().active || !r.context || !e.surface_api.ready) return 0;
std::array<std::uintptr_t, 2> context{};
r.context(context.data());
return context[0] ? e.surface_api.world(context[0]) : 0;
} catch (...) {
return 0;
}
}
bool physics_body_valid(const world_entities::Body &b) noexcept {
const auto &api = editor_state().picking_api;
if (!api.ready) return false;
alignas(16) editor::SurfaceBody body{b[0], b[1]};
return api.body_valid(body.data());
}
std::uint32_t physics_body_owner(const world_entities::Body &b) noexcept {
const auto store = pointer_at(static_cast<std::uintptr_t>(b[0]) + pb::store_offset);
std::uint32_t owner{UINT32_MAX};
if (store) memory::read(pointer_at(store + pb::body_owner_offset) + std::uintptr_t{static_cast<std::uint32_t>(b[1])} * 4, owner);
return owner;
}
bool physics_body_ghost(const world_entities::Body &b, bool ghost) noexcept {
const auto base = local_runtime().base;
static const bool known = [&] {
const auto same = [&](std::uintptr_t rva, const std::array<unsigned char, 24> &prefix) {
std::array<unsigned char, 24> bytes{};
return memory::read_bytes(base + rva, bytes.data(), bytes.size()) && bytes == prefix;
};
return same(pb::ghost_body, pb::ghost_body_prefix) && same(pb::solid_body, pb::solid_body_prefix) &&
same(pb::main_ghost, pb::main_ghost_prefix) && same(pb::main_solid, pb::main_solid_prefix);
}();
static std::atomic<unsigned> logged{};
if (!known || !physics_body_valid(b)) {
if (logged.fetch_add(1) < 4)
logging::log(logging::Level::info, logging::Channel::runtime, "World objects: body {} not {}: {}.", static_cast<std::uint32_t>(b[1]),
ghost ? "ghosted" : "made solid", known ? "the world says it is not valid" : "the physics functions differ");
return false;
}
// Both halves of the game's own ghosting, on every body of the object.
alignas(16) std::array<std::uint64_t, 2> body{b[0], b[1]};
reinterpret_cast<void (*)(std::uint64_t *)>(base + (ghost ? pb::main_ghost : pb::main_solid))(body.data());
reinterpret_cast<void (*)(std::uint64_t *)>(base + (ghost ? pb::ghost_body : pb::solid_body))(body.data());
if (logged.fetch_add(1) < 8)
logging::log(logging::Level::info, logging::Channel::runtime, "World objects: body {} {}.", static_cast<std::uint32_t>(b[1]),
ghost ? "ghosted" : "made solid");
return true;
}
std::optional<float> physics_floor(const std::array<float, 3> &from, float depth, const std::vector<world_entities::Body> &ignored) noexcept {
try {
std::lock_guard lock(local_runtime().native_mutex);
auto &e = editor_state();
const auto world = physics_world();
if (!world || !e.surface_api.ready) return std::nullopt;
std::vector<editor::SurfaceBody> bodies;
for (const auto &body : ignored) bodies.push_back({body[0], body[1]});
const auto hit = editor::cast_surface(e.surface_api, world, {from[0], from[1] + 0.25f, from[2]}, {from[0], from[1] - depth, from[2]}, nullptr, {},
nullptr, bodies);
if (!hit) return std::nullopt;
return (*hit)[1];
} catch (...) {
return std::nullopt;
}
}
// The body a ray meets first: the surface cast's owner is asked for the nearer hit each time, so the
// last body it is asked about is the closest.
thread_local editor::SurfaceBody closest_body{};
std::uint64_t remember_body(std::uintptr_t world, std::uint32_t index, std::uintptr_t data) {
editor::SurfaceBody body{};
if (surface_body(world, index, data, body)) closest_body = body;
return 0;
}
std::optional<world_entities::Body> physics_hit(const std::array<float, 3> &from, const std::array<float, 3> &to) noexcept {
try {
std::lock_guard lock(local_runtime().native_mutex);
auto &e = editor_state();
const auto world = physics_world();
if (!world || !e.surface_api.ready || !e.picking_api.ready) return std::nullopt;
closest_body = {};
if (!editor::cast_surface(e.surface_api, world, from, to, &remember_body) || !closest_body[0]) return std::nullopt;
return world_entities::Body{closest_body[0], closest_body[1]};
} catch (...) {
return std::nullopt;
}
}
// Every body of the body's owner: the store's body-to-owner array (+0x1008, u32 each) is a vector
// whose end is at +0x1010; empty when that does not read as one, or the owner has more than `most`.
std::vector<world_entities::Body> physics_owned(const world_entities::Body &b, std::size_t most) noexcept {
try {
const auto world = static_cast<std::uintptr_t>(b[0]);
const auto store = pointer_at(world + pb::store_offset);
const auto begin = pointer_at(store + pb::body_owner_offset), end = pointer_at(store + pb::body_owner_offset + 8);
static std::atomic<bool> logged{};
if (!logged.exchange(true))
logging::log(logging::Level::info, logging::Channel::runtime, "World objects: the body owners at {:#x}..{:#x} ({} bodies), then {:#x}.", begin,
end, end > begin ? (end - begin) / 4 : 0, pointer_at(store + pb::body_owner_offset + 16));
if (!begin || end <= begin || (end - begin) / 4 > (1u << 24)) return {};
const auto count = static_cast<std::uint32_t>((end - begin) / 4);
const auto index = static_cast<std::uint32_t>(b[1]);
if (index >= count) return {};
std::uint32_t owner{};
if (!memory::read(begin + std::uintptr_t{index} * 4, owner) || owner == UINT32_MAX) return {};
// Read in pieces: the array is only partly backed (unused bodies are UINT32_MAX, and whole
// pages of them may not be there to read).
constexpr std::uint32_t piece = 0x4000;
std::vector<std::uint32_t> owners(piece);
std::vector<world_entities::Body> bodies;
for (std::uint32_t first = 0; first < count; first += piece) {
const auto n = std::min(piece, count - first);
if (!memory::read_bytes(begin + std::uintptr_t{first} * 4, owners.data(), std::size_t{n} * 4)) continue;
for (std::uint32_t j = 0; j < n; ++j) {
const auto i = first + j;
if (owners[j] != owner) continue;
if (bodies.size() >= most) return {};
// Its generation as the store keeps it (+0xff8, a byte per body), as body_valid checks.
std::uint8_t generation{};
if (!memory::read(pointer_at(store + 0xff8) + i, generation)) continue;
const world_entities::Body body{b[0], std::uint64_t{i} | (std::uint64_t{generation} << 32)};
if (physics_body_valid(body)) bodies.push_back(body);
}
}
static std::atomic<unsigned> owner_logged{};
if (owner_logged.fetch_add(1) < 8)
logging::log(logging::Level::info, logging::Channel::runtime, "World objects: body {} belongs to owner {}, which has {} bod{}.", index, owner,
bodies.size(), bodies.size() == 1 ? "y" : "ies");
return bodies;
} catch (...) {
return {};
}
}
// Test only (objbody): the store's per-body bit setters next to the ghost ones (each sets, or
// with "off" clears, one bit per body and some tell the world), to learn which one takes a body
// out of the skater's collision.
bool physics_test(std::string_view name, const world_entities::Body &b) noexcept {
static constexpr std::pair<std::string_view, std::uintptr_t> setters[]{
{"a70", 0x25d38a0}, {"a70off", 0x25d3250}, {"f88", 0x25d3930}, {"4c0", 0x25d33d0}, {"4c0off", 0x25d2ec0},
{"310", 0x25d3560}, {"310off", 0x25d3090}, {"830", 0x25d3590}, {"830off", 0x25d30c0}, {"8d0", 0x25d36a0},
{"8d8", 0x25d3900}, {"aa0", 0x25d3670}, {"aa0off", 0x25d31d0}, {"1598", 0x25d3400}, {"40", 0x25d3330},
{"40off", 0x25d2e60}, {"1680", 0x25d3530}};
const auto found = std::find_if(std::begin(setters), std::end(setters), [&](const auto &s) { return s.first == name; });
if (found == std::end(setters) || !physics_body_valid(b)) return false;
alignas(16) std::array<std::uint64_t, 2> body{b[0], b[1]};
reinterpret_cast<void (*)(std::uint64_t *)>(local_runtime().base + found->second)(body.data());
return true;
}
} // namespace
void register_world_physics() noexcept {
static std::once_flag once;
std::call_once(once, [] {
world_entities::set_physics(
{&physics_world, &physics_body_valid, &physics_body_owner, &physics_body_ghost, &physics_floor, &physics_hit, &physics_owned,
&physics_test});
});
}
}
+8
View File
@@ -1,7 +1,15 @@
#include "Extension/Console/commands.h"
#include "Extension/Profile/local_profile_runtime.h"
#include "Extension/World/world_entities.h"
namespace dingosdk::console {
void register_object_commands(Commands &registry) {
// Test only: a per-body physics setter on plugins' world objects (world_entities.h test_bodies).
auto body_test = action("objbody", "Test: run a physics body setter on plugins' world objects", Group::objects, {argument("setter")});
body_test.run = [](const Model &, const Values &args, const Output &out) {
world_entities::test_bodies(std::get<std::string>(args[0]));
out("Queued; the log says which bodies it ran on.");
};
registry.add(std::move(body_test));
auto enabled = variable("objects enabled", "Persist placed objects between sessions", Group::objects,
argument("0|1", Type::boolean));
enabled.aliases = {"objectpersistence"};
+77 -3
View File
@@ -121,6 +121,10 @@ struct FakeGame final : Game {
calls.push_back(std::format("xray {} {}", plugin, on ? "on" : "off"));
return {};
}
std::string hide_skater(std::string_view plugin, bool hidden) override {
calls.push_back(std::format("hide skater {} {}", plugin, hidden ? "on" : "off"));
return {};
}
std::string hud_corner(std::string_view plugin, bool hidden) override {
calls.push_back(std::format("corner {} {}", plugin, hidden ? "on" : "off"));
return {};
@@ -239,14 +243,31 @@ struct FakeGame final : Game {
// World objects as the fake keeps them: by plugin and id, ready at once unless hidden.
std::map<std::pair<std::string, std::uint32_t>, std::pair<std::string, std::array<float, 16>>> entities;
std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array<float, 16>& m, bool hidden,
std::optional<EntityAttach> attach) override {
std::optional<EntityAttach> attach, bool solid) override {
if (entities.size() >= 600) return "too many";
entities[{std::string(plugin), id}] = {std::string(asset) + (hidden ? " hidden" : ""), m};
calls.push_back(std::format("entity {} {} {} {:.2f} {:.2f} {:.2f} {:.2f}{}{}", plugin, id, asset, m[12], m[13], m[14], m[8], hidden ? " hidden" : "",
attach ? std::format(" on {} {}", attach->player, attach->joint) : std::string{}));
attach ? std::format(" on {} {}{}{}{}", attach->player, attach->joint, attach->upright ? " upright" : "", attach->turn ? "" : " held",
attach->ground > 0 ? std::format(" ground {:.1f}", attach->ground) : std::string{}) : std::string{}) +
(solid ? "" : " ghost"));
return {};
}
void entity_remove(std::string_view plugin, std::uint32_t id) override { entities.erase({std::string(plugin), id}); }
std::optional<std::array<Vec3, 2>> entity_bounds(std::string_view blueprint) override {
if (blueprint != "World/Props/TrashCan_01") return std::nullopt;
return std::array<Vec3, 2>{Vec3{-0.3f, 0, -0.3f}, Vec3{0.3f, 0.9f, 0.3f}};
}
std::vector<std::string> entity_blueprints(std::string_view prefix, bool meshes_only) override {
std::vector<std::string> out;
for (const auto* name : {"World/Props/Bench_01", "World/Props/TrashCan_01", "World/Vehicles/Car_01"})
if (std::string_view(name).starts_with(prefix) && (!meshes_only || entity_bounds(name))) out.emplace_back(name);
return out;
}
std::optional<WorldPick> world_pick(const Vec3& from, const Vec3&) override {
if (from[1] > 50) return std::nullopt; // the sky: nothing met
WorldPick pick{{1, 0, 2}, true, std::array<float, 16>{0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 3, 1}, "World/Props/TrashCan_01"};
return pick;
}
void entity_clear(std::string_view plugin) override {
std::erase_if(entities, [&](const auto& e) { return e.first.first == plugin; });
calls.push_back("entities clear " + std::string(plugin));
@@ -769,6 +790,9 @@ void a_plugin_loads_ticks_runs_its_commands_and_unloads() {
reskate.console.print("hello " .. table.concat(args, ",") .. " after " .. ticks)
end)
print("printed", 1, true)
reskate.console.command("greet", "Greets", function(args) reskate.console.print("greet " .. (args[1] or "")) end,
{{name = "loud", args = "[name]", help = "Greets loudly"}, {name = "soft"}})
assert(not pcall(reskate.console.command, "bad", "x", function() end, {{name = "Two Words"}}), "a subcommand is one word")
)", R"("console")")});
running(host, "hello", environment);
check(environment.logged("[hello] loaded hello api 1"), "The load handler ran\n" + environment.all());
@@ -778,7 +802,14 @@ void a_plugin_loads_ticks_runs_its_commands_and_unloads() {
check(environment.printed("hello a,b after 3"), "with its arguments, after its ticks");
check(host.run_console("HELLO There"), "whatever the case");
check(!host.run_console("nothing here"), "and other text is left to ReSkate");
check(status_of(host, "hello").commands.size() == 1, "Its command is listed");
check(status_of(host, "hello").commands.size() == 4, "Its commands are listed, with the subcommands one declared");
{
const auto commands = status_of(host, "hello").commands;
check(std::any_of(commands.begin(), commands.end(),
[](const auto& c) { return c.name == "greet loud" && c.args == "[name]" && c.help == "Greets loudly"; }),
"A subcommand is listed with its arguments and help");
}
check(host.run_console("greet loud") && environment.printed("greet loud"), "and its command's function runs it");
host.apply({});
check(environment.logged("[hello] bye after 3"), "Unloading runs its unload handler");
check(host.status().empty(), "and it is gone");
@@ -1220,6 +1251,48 @@ void plugins_place_world_entities() {
check(environment.logged("[sneaky] false"), "world.entities is needed\n" + environment.all());
}
// What a prop hunt needs: the props this map has, the one a ray meets (to become it), wearing it
// upright on another player, and your own skater hidden on your screen.
void plugins_have_what_a_prop_hunt_needs() {
TestEnvironment environment;
auto& game = environment.fake;
Host host(environment, Side::client);
const std::string main = R"(
local props = reskate.entities.blueprints("World/Props/")
assert(#props == 2 and props[1] == "World/Props/Bench_01", "the props loaded, by their names' start")
assert(#reskate.entities.blueprints() == 3, "all of them")
local shown = reskate.entities.blueprints("", true)
assert(#shown == 1 and shown[1] == "World/Props/TrashCan_01", "only those with a mesh")
local low, high = reskate.entities.bounds("World/Props/TrashCan_01")
assert(low.y == 0 and math.abs(high.y - 0.9) < 1e-5, "its box")
assert(reskate.entities.bounds("World/Vehicles/Car_01") == nil, "none without a mesh")
local hit = reskate.world.pick(vector.create(0, 2, 0), vector.create(0, -2, 0))
assert(hit and hit.position.z == 2 and hit.object.blueprint == "World/Props/TrashCan_01", "the object a ray meets")
assert(math.abs(hit.object.heading - math.pi / 2) < 1e-4 and hit.object.position.z == 3, "where it is and how it is turned")
assert(#hit.object.transform.matrix == 16)
assert(reskate.world.pick(vector.create(0, 60, 0), vector.create(0, 70, 0)) == nil, "nothing met")
assert(reskate.entities.set(1, hit.object.blueprint, hit.object.transform, false,
{player = "76561198000000001", joint = "root", upright = true}), "worn upright, at the feet")
assert(reskate.entities.set(2, hit.object.blueprint, {position = vector.create(0, 0, 0), yaw = 45}, false,
{joint = "root", turn = false}), "turned as the mod says, whatever the skater does")
assert(reskate.entities.set(3, hit.object.blueprint, {position = vector.create(0, 0, 0)}, false,
{joint = "hips", turn = false, ground = 1.5}, {solid = false}), "on the floor, not solid")
assert(not pcall(reskate.entities.set, 4, hit.object.blueprint, {position = vector.create(0, 0, 0)}, false, {joint = "hips", ground = 80}),
"the floor within 50 m")
assert(reskate.skater.hide(true))
reskate.log("disguised")
)";
host.apply({package("prophunt", {{manifest_file, manifest(R"("world.entities", "world.raycast", "skater.control")")}, {"plugin/main.lua", main}})});
ticks(host, environment, 1);
running(host, "prophunt", environment);
check(environment.logged("[prophunt] disguised"), "A prop hunt finds, becomes and wears a prop\n" + environment.all());
check(game.called("entity prophunt 1 World/Props/TrashCan_01 1.00 0.00 3.00 1.00 on 76561198000000001 1 upright"),
"the prop on the hider's hips, upright\n" + environment.all());
check(game.called("hide skater prophunt on"), "your own skater hidden");
check(game.called("entity prophunt 2 World/Props/TrashCan_01 0.00 0.00 0.00 0.71 on 0 1 held"), "a prop the skater does not turn\n" + environment.all());
check(game.called("entity prophunt 3 World/Props/TrashCan_01 0.00 0.00 0.00 0.00 on 0 7 held ground 1.5 ghost"), "on the floor and not solid\n" + environment.all());
}
// What a camera mode needs (the Filmer): an arm that reaches, where the hand is, cruise control, the
// mouse, and the right stick and stick clicks kept from the game.
void plugins_reach_cruise_and_read_the_mouse() {
@@ -3541,6 +3614,7 @@ int main() {
run(plugins_draw_on_the_overlay);
run(plugins_draw_pictures);
run(plugins_place_world_entities);
run(plugins_have_what_a_prop_hunt_needs);
run(plugins_reach_cruise_and_read_the_mouse);
run(plugins_read_joints_and_tune_physics);
run(plugins_type_and_use_the_room);
+29 -1
View File
@@ -412,8 +412,36 @@ struct ApiFunctions {
if (plugin.commands_.contains(name)) luaL_error(L, "the command \"%s\" is registered already", name.c_str());
if (const auto refusal = plugin.services_.claim_command(plugin.id(), name); !refusal.empty())
luaL_error(L, "%s", refusal.c_str());
// subcommands = {{name = "random", args = "[start]", help = "..."}, ...}: what the console's
// lookup and `plugin commands` show after the name. The function still runs them all.
std::vector<Plugin::Subcommand> subcommands;
if (!lua_isnoneornil(L, 4)) {
luaL_checktype(L, 4, LUA_TTABLE);
const int count = static_cast<int>(lua_objlen(L, 4));
if (count > 32) luaL_error(L, "at most 32 subcommands");
for (int i = 1; i <= count; ++i) {
lua_rawgeti(L, 4, i);
if (!lua_istable(L, -1)) luaL_error(L, "subcommand %d is not a table {name = ..., args = ..., help = ...}", i);
Plugin::Subcommand sub;
const auto field = [&](const char* key, std::size_t most) {
lua_getfield(L, -1, key);
std::string value = lua_isnil(L, -1) ? std::string{} : std::string(luaL_checkstring(L, -1));
lua_pop(L, 1);
if (value.size() > most) luaL_error(L, "a subcommand's %s is at most %d characters", key, static_cast<int>(most));
return value;
};
sub.name = field("name", maximum_command);
sub.args = field("args", 80);
sub.help = field("help", 200);
bool word = !sub.name.empty();
for (const char c : sub.name) word = word && ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-');
if (!word) luaL_error(L, "subcommand %d's name is not one lowercase word of a-z, 0-9, _ and -", i);
subcommands.push_back(std::move(sub));
lua_pop(L, 1);
}
}
lua_pushvalue(L, 3);
plugin.commands_[name] = {help.substr(0, 200), lua_ref(L, -1)};
plugin.commands_[name] = {help.substr(0, 200), lua_ref(L, -1), std::move(subcommands)};
lua_pop(L, 1);
return 0;
}
+113 -9
View File
@@ -108,6 +108,12 @@ struct WorldApi {
need(L, plugin, "overlay", "reskate.skater.xray");
return result(L, game(plugin).xray(plugin.id(), lua_toboolean(L, 1) != 0));
}
// skater.hide(on): the local skater not drawn on this screen while on (nothing: true).
static int hide_skater(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "skater.control", "reskate.skater.hide");
return result(L, game(plugin).hide_skater(plugin.id(), lua_isnone(L, 1) || lua_toboolean(L, 1) != 0));
}
// skater.shape_launch(from, shape): the launches out of `from` ("grind", "ollie", "no_comply" or
// "boneless") from now on, {across =, along =, up =} in m/s, each set if given (across the
// direction of travel, + its left); nil the game's own.
@@ -442,8 +448,10 @@ struct WorldApi {
const auto id = entity_id(L, 1);
const auto asset = checked_text(L, 2, 200, "a blueprint's name");
const auto matrix = transform_of(L, 3);
// attach = {player = id?, joint = number or name}: kept on that skater's joint, the transform
// in the joint's frame.
// entities.set(id, blueprint, transform, hidden?, attach?, options?).
// attach = {player = id?, joint = number or name, upright = bool?, turn = bool?, ground = metres?}: kept on that
// skater's joint, the transform in the joint's frame (upright: level, turned only by the joint's
// heading; turn = false: the joint's position only, the transform's rotation in the world).
std::optional<Game::EntityAttach> attach;
if (!lua_isnoneornil(L, 5)) {
luaL_checktype(L, 5, LUA_TTABLE);
@@ -454,8 +462,31 @@ struct WorldApi {
lua_getfield(L, 5, "joint");
attach->joint = joint_of(L, lua_gettop(L));
lua_pop(L, 1);
lua_getfield(L, 5, "upright");
attach->upright = lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
lua_getfield(L, 5, "turn");
attach->turn = lua_isnil(L, -1) || lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
// ground = metres (or true: 2 m) below the joint to stand the object on the floor in.
lua_getfield(L, 5, "ground");
if (lua_isboolean(L, -1)) attach->ground = lua_toboolean(L, -1) ? 2.0f : 0.0f;
else if (!lua_isnil(L, -1)) {
const double reach = luaL_checknumber(L, -1);
if (!(reach >= 0 && reach <= 50)) luaL_error(L, "an object finds the ground at most 50 m below its joint");
attach->ground = static_cast<float>(reach);
}
lua_pop(L, 1);
}
return result(L, game(plugin).entity_set(plugin.id(), id, asset, matrix, lua_toboolean(L, 4) != 0, attach));
// options = {solid = false}: skaters go through the object.
bool solid = true;
if (!lua_isnoneornil(L, 6)) {
luaL_checktype(L, 6, LUA_TTABLE);
lua_getfield(L, 6, "solid");
solid = lua_isnil(L, -1) || lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
}
return result(L, game(plugin).entity_set(plugin.id(), id, asset, matrix, lua_toboolean(L, 4) != 0, attach, solid));
}
static int entities_remove(lua_State* L) {
auto& plugin = plugin_of(L);
@@ -484,6 +515,78 @@ struct WorldApi {
lua_pushboolean(L, game(plugin).entities_ready(plugin.id()));
return 1;
}
// entities.blueprints(prefix?, meshes_only?): the world object blueprints loaded now, sorted;
// with meshes_only, only those with a loaded mesh (the ones that show).
static int entities_blueprints(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "world.entities", "reskate.entities.blueprints");
const std::string prefix = lua_isnoneornil(L, 1) ? std::string{} : checked_text(L, 1, 200, "a blueprint name's start");
const auto names = game(plugin).entity_blueprints(prefix, lua_toboolean(L, 2) != 0);
lua_createtable(L, static_cast<int>(names.size()), 0);
int index = 0;
for (const auto& name : names) {
lua_pushlstring(L, name.data(), name.size());
lua_rawseti(L, -2, ++index);
}
return 1;
}
// entities.bounds(blueprint): its mesh box in its own frame, min and max, or nil.
static int entities_bounds(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "world.entities", "reskate.entities.bounds");
const auto box = game(plugin).entity_bounds(checked_text(L, 1, 200, "a blueprint's name"));
if (!box) {
lua_pushnil(L);
return 1;
}
push_position(L, (*box)[0]);
push_position(L, (*box)[1]);
return 2;
}
// world.pick(from, to): where the ray meets the world, and the object it meets: {position,
// object = {blueprint?, position, heading, transform = {matrix}}?}, or nil when it is clear.
static int world_pick(lua_State* L) {
auto& plugin = plugin_of(L);
need(L, plugin, "world.raycast", "reskate.world.pick");
const auto from = position_of(L, 1, "the ray's start");
const auto to = position_of(L, 2, "the ray's end");
const float dx = to[0] - from[0], dy = to[1] - from[1], dz = to[2] - from[2];
if (std::sqrt(dx * dx + dy * dy + dz * dz) > plugin.limits_.raycast_metres)
luaL_error(L, "a ray is at most %d m long", static_cast<int>(plugin.limits_.raycast_metres));
if (++plugin.raycasts_ > plugin.limits_.raycasts_per_tick)
luaL_error(L, "more than %d raycasts in one tick", plugin.limits_.raycasts_per_tick);
const auto pick = game(plugin).world_pick(from, to);
if (!pick) {
lua_pushnil(L);
return 1;
}
lua_createtable(L, 0, 2);
push_position(L, pick->at);
lua_setfield(L, -2, "position");
if (pick->object) {
lua_createtable(L, 0, 4);
if (!pick->blueprint.empty()) {
lua_pushlstring(L, pick->blueprint.data(), pick->blueprint.size());
lua_setfield(L, -2, "blueprint");
}
if (const auto& m = pick->matrix) {
push_position(L, Vec3{(*m)[12], (*m)[13], (*m)[14]});
lua_setfield(L, -2, "position");
lua_pushnumber(L, std::atan2((*m)[8], (*m)[10])); // the forward row's heading, as the skater's
lua_setfield(L, -2, "heading");
lua_createtable(L, 0, 1);
lua_createtable(L, 16, 0);
for (int i = 0; i < 16; ++i) {
lua_pushnumber(L, (*m)[i]);
lua_rawseti(L, -2, i + 1);
}
lua_setfield(L, -2, "matrix");
lua_setfield(L, -2, "transform");
}
lua_setfield(L, -2, "object");
}
return 1;
}
static int place(lua_State* L) {
need(L, plugin_of(L), "world.objects", "reskate.objects.place");
auto object = pose(L, 2, 3, 4);
@@ -1155,14 +1258,14 @@ struct WorldApi {
static std::uint16_t joint_of(lua_State* L, int index) {
if (lua_type(L, index) == LUA_TSTRING) {
static constexpr std::pair<std::string_view, std::uint16_t> named[] = {
{"right_hand", 49}, {"left_hand", 278}, {"head", 103}, {"hips", 7}, {"spine", 42}};
{"right_hand", 49}, {"left_hand", 278}, {"head", 103}, {"hips", 7}, {"spine", 42}, {"root", 1}};
const std::string_view name = lua_tostring(L, index);
const auto at = std::find_if(std::begin(named), std::end(named), [&](const auto& n) { return n.first == name; });
if (at == std::end(named)) luaL_error(L, "a joint is a number 0 to 394, or right_hand, left_hand, head, hips or spine");
if (at == std::end(named)) luaL_error(L, "a joint is a number 0 to 394, or right_hand, left_hand, head, hips, spine or root");
return at->second;
}
const double joint = luaL_checknumber(L, index);
if (!(joint >= 0 && joint <= 394) || joint != static_cast<int>(joint)) luaL_error(L, "a joint is a number 0 to 394, or right_hand, left_hand, head, hips or spine");
if (!(joint >= 0 && joint <= 394) || joint != static_cast<int>(joint)) luaL_error(L, "a joint is a number 0 to 394, or right_hand, left_hand, head, hips, spine or root");
return static_cast<std::uint16_t>(joint);
}
static int push_joint(lua_State* L, const std::optional<Game::JointPlace>& at, std::uint16_t joint) {
@@ -2352,7 +2455,7 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
lua_setfield(L, -2, "__metatable");
lua_setfield(L, LUA_REGISTRYINDEX, "reskate.permissions");
add_functions(L, "skater", {{"state", state}, {"noclip", noclip}, {"boost", boost}, {"freeze", freeze}, {"wipeout", wipeout}, {"xray", xray},
{"shape_launch", shape_launch}, {"reach", reach}, {"hand", hand}, {"joint", skater_joint}, {"pose", skater_pose}});
{"shape_launch", shape_launch}, {"hide", hide_skater}, {"reach", reach}, {"hand", hand}, {"joint", skater_joint}, {"pose", skater_pose}});
add_functions(L, "board", {{"motion", board_motion}, {"bones", board_bones}, {"set", board_set}, {"hide", board_hide}, {"clear", board_clear},
{"park", board_park}, {"fit", board_fit}, {"toss", board_toss}});
add_functions(L, "camera", {{"look", camera_look}, {"set", camera_set}, {"follow", camera_follow}, {"release", camera_release}, {"held", camera_held}, {"get", camera_get}});
@@ -2361,8 +2464,9 @@ void WorldApi::install(Plugin& plugin, lua_State* L) {
add_functions(L, "time", {{"slow", slow}, {"speed", speed}});
add_functions(L, "objects", {{"catalog", catalog}, {"list", objects}, {"place", place}, {"move", move}, {"remove", remove}});
add_functions(L, "entities", {{"set", entities_set}, {"remove", entities_remove}, {"clear", entities_clear}, {"status", entities_status},
{"ready", entities_ready}});
add_functions(L, "world", {{"set_time", set_time}, {"set_control", set_control}, {"set_park", set_park}});
{"ready", entities_ready}, {"blueprints", entities_blueprints},
{"bounds", entities_bounds}});
add_functions(L, "world", {{"set_time", set_time}, {"set_control", set_control}, {"set_park", set_park}, {"pick", world_pick}});
add_functions(L, "travel", {{"locations", locations}, {"levels", levels}, {"go", go}, {"load", load}});
add_functions(L, "ai", {{"spawn", ai_spawn}, {"clear", ai_clear}, {"count", ai_count}});
add_functions(L, "session", {{"skaters", skaters}, {"tag", tag}, {"joint", session_joint}, {"touch", session_touch},
+21 -1
View File
@@ -283,15 +283,33 @@ public:
struct EntityAttach {
std::uint64_t player{};
std::uint16_t joint{};
bool upright{}; // level at the joint, turned only by its heading
bool turn{true}; // false: the joint's position only, the transform's rotation in the world
float ground{}; // metres below the joint to find the floor in, 0 none
};
virtual std::string entity_set(std::string_view /*plugin*/, std::uint32_t, std::string_view, const std::array<float, 16>&, bool,
std::optional<EntityAttach> /*attach*/) {
std::optional<EntityAttach> /*attach*/, bool /*solid*/) {
return unavailable;
}
virtual void entity_remove(std::string_view /*plugin*/, std::uint32_t) {}
virtual void entity_clear(std::string_view /*plugin*/) {}
virtual int entity_status(std::string_view /*plugin*/, std::uint32_t) { return 0; }
virtual bool entities_ready(std::string_view /*plugin*/) { return false; }
// The world object blueprints loaded now, those starting with `prefix` (case ignored), sorted
// (Extension/World/world_entities.h).
virtual std::vector<std::string> entity_blueprints(std::string_view /*prefix*/, bool /*meshes_only*/) { return {}; }
// A world object blueprint's mesh box in its own frame (min, max), when it has a loaded mesh.
virtual std::optional<std::array<Vec3, 2>> entity_bounds(std::string_view /*blueprint*/) { return std::nullopt; }
// The world raycast and the object it meets (Extension/Objects/ParkEditor/park_editor_runtime.h,
// local_world_pick): where; and the object's transform (rows right, up, forward with their
// scale, then position) and blueprint, when known.
struct WorldPick {
Vec3 at{};
bool object{};
std::optional<std::array<float, 16>> matrix;
std::string blueprint;
};
virtual std::optional<WorldPick> world_pick(const Vec3& /*from*/, const Vec3& /*to*/) { return std::nullopt; }
// kind "tod" (key: morning, noon, afternoon, evening, night, weatherday, weathernight, default),
// "control" (key: fog, fog_distance, sky_brightness, clouds, wind_strength, wind_direction,
// traffic, pedestrians; value a number, -1 the default) or "park" (key: a lot, value a layout).
@@ -723,6 +741,8 @@ public:
virtual std::vector<PhysicsStep> take_physics_steps() { return {}; }
// The skater's x-ray ready while any plugin asks (Extension/Skater/xray.h); d:skeleton draws it.
virtual std::string xray(std::string_view /*plugin*/, bool) { return unavailable; }
// The local skater not drawn on this screen while any plugin asks (Extension/Skater/xray.h).
virtual std::string hide_skater(std::string_view /*plugin*/, bool) { return unavailable; }
// skate.'s bottom left HUD hidden while any plugin asks; false keeps the plugin ready to hide it.
virtual std::string hud_corner(std::string_view /*plugin*/, bool) { return unavailable; }
// The game run at `speed` (0.05 to 1; 1 drops the ask) while it is the slowest asked; never in a
+38 -3
View File
@@ -226,10 +226,12 @@ public:
return status.starts_with("error: ") ? status.substr(7) : std::string{};
}
std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array<float, 16>& matrix, bool hidden,
std::optional<EntityAttach> attach) override {
if (!attach) return world_entities::set(plugin, id, std::string(asset), matrix, hidden);
std::optional<EntityAttach> attach, bool solid) override {
register_world_physics();
if (!attach) return world_entities::set(plugin, id, std::string(asset), matrix, hidden, std::nullopt, solid);
world_entities::set_joint_source(&joint_matrix);
return world_entities::set(plugin, id, std::string(asset), matrix, hidden, world_entities::Attach{attach->player, attach->joint});
return world_entities::set(plugin, id, std::string(asset), matrix, hidden,
world_entities::Attach{attach->player, attach->joint, attach->upright, attach->turn, attach->ground}, solid);
}
// Where a skater's joint is (world_entities' attach): the local skater's as its pose is evaluated,
// another player's as the session draws it.
@@ -261,6 +263,34 @@ public:
void entity_clear(std::string_view plugin) override { world_entities::clear(plugin); }
int entity_status(std::string_view plugin, std::uint32_t id) override { return static_cast<int>(world_entities::status(plugin, id)); }
bool entities_ready(std::string_view plugin) override { return world_entities::ready(plugin); }
std::vector<std::string> entity_blueprints(std::string_view prefix, bool meshes_only) override {
return world_entities::blueprints(base_, prefix, 4096, meshes_only);
}
std::optional<std::array<Vec3, 2>> entity_bounds(std::string_view blueprint) override {
const auto box = world_entities::blueprint_bounds(base_, blueprint);
if (!box) return std::nullopt;
return std::array<Vec3, 2>{(*box)[0], (*box)[1]};
}
std::optional<WorldPick> world_pick(const Vec3& from, const Vec3& to) override {
const auto pick = local_world_pick(from, to);
if (!pick) return std::nullopt;
WorldPick out{pick->at, pick->entity != 0 || !pick->blueprint.empty(), std::nullopt, pick->blueprint};
if (pick->pose) {
// The pose: scale xyz, -, rotation xyzw, position xyz. Rows: the rotated x, y and z axes
// times their scale (the same rotation objects.list's heading reads), then position.
const auto& p = *pick->pose;
const float x = p[4], y = p[5], z = p[6], w = p[7];
const std::array<std::array<float, 3>, 3> axes{{{1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w)},
{2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w)},
{2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y)}}};
std::array<float, 16> m{};
for (int row = 0; row < 3; ++row)
for (int k = 0; k < 3; ++k) m[row * 4 + k] = axes[row][k] * p[row];
m[12] = p[8], m[13] = p[9], m[14] = p[10], m[15] = 1;
out.matrix = m;
}
return out;
}
std::string set_environment(std::string_view kind, std::string_view key, std::string_view value) override {
if (kind == "tod") return time_of_day(key);
if (kind == "control") {
@@ -564,6 +594,10 @@ public:
xray::prepare(plugin, on);
return {};
}
std::string hide_skater(std::string_view plugin, bool hidden) override {
if (!xray::hide(plugin, hidden)) return "hiding the skater is unavailable for this game build";
return {};
}
std::string hud_corner(std::string_view plugin, bool hidden) override {
hud_corner::ask(plugin, hidden);
return {};
@@ -1557,6 +1591,7 @@ public:
(void)freeze(plugin, false);
hide_hud(plugin, false);
xray::prepare(plugin, false);
xray::hide(plugin, false);
hud_corner::release(plugin);
game_speed::ask(plugin, 1.0f);
held_settings::release(plugin);
+1 -1
View File
@@ -74,7 +74,7 @@ struct Status {
std::string reason; // why it is not running
std::size_t memory{};
double tick_ms{};
std::vector<std::pair<std::string, std::string>> commands;
std::vector<CommandInfo> commands; // with the subcommands they declared
};
class Host final : private Services {
+23 -2
View File
@@ -454,9 +454,10 @@ std::string list_commands() {
std::string text;
const auto add = [&](const std::vector<Status>& all, std::string_view suffix) {
for (const auto& plugin : all)
for (const auto& [name, help] : plugin.commands) {
for (const auto& command : plugin.commands) {
if (!text.empty()) text += '\n';
text += std::format("{} - {} ({}{})", name, help, plugin.id, suffix);
text += std::format("{}{}{} - {} ({}{})", command.name, command.args.empty() ? "" : " ", command.args, command.help, plugin.id,
suffix);
}
};
add(host->status(), "");
@@ -464,6 +465,26 @@ std::string list_commands() {
return text.empty() ? "No plugin has a console command." : text;
}
std::vector<CommandHint> command_hints() {
static std::vector<CommandHint> hints;
static std::uint64_t next{};
const auto now = GetTickCount64();
if (now < next) return hints;
next = now + 500;
hints.clear();
if (!host) return hints;
const auto add = [&](const std::vector<Status>& all, std::string_view suffix) {
for (const auto& plugin : all)
for (const auto& command : plugin.commands)
if (std::none_of(hints.begin(), hints.end(), [&](const auto& hint) { return hint.name == command.name; }))
hints.push_back({command.name, command.args, command.help, plugin.id + std::string(suffix)});
};
add(host->status(), "");
if (serving) add(server_host->status(), "@server");
std::sort(hints.begin(), hints.end(), [](const auto& a, const auto& b) { return a.name < b.name; });
return hints;
}
// "<id>@server" names a server copy (on this game while it hosts a lobby); a plain id, the player's copy.
namespace {
template <class Do> std::string either(std::string_view id, Do&& act) {
+6
View File
@@ -26,6 +26,12 @@ std::vector<std::vector<std::uint8_t>> take_outgoing() noexcept;
// The `plugin` console commands.
std::string describe();
std::string list_commands();
// The console commands plugins added, for the console's lookup: each name, its help and the plugin
// that owns it (a server copy's as "<id>@server"). Client thread; read again at most twice a second.
struct CommandHint {
std::string name, args, help, plugin; // name may be several words: a declared subcommand
};
std::vector<CommandHint> command_hints();
std::string reload(std::string_view id);
std::string switch_off(std::string_view id);
std::string switch_on(std::string_view id);
+6 -3
View File
@@ -771,9 +771,12 @@ void Plugin::set_channel(std::string channel) {
channel_hash_ = wire::channel_of(channel_);
}
std::vector<std::pair<std::string, std::string>> Plugin::commands() const {
std::vector<std::pair<std::string, std::string>> result;
for (const auto& [name, command] : commands_) result.emplace_back(name, command.help);
std::vector<CommandInfo> Plugin::commands() const {
std::vector<CommandInfo> result;
for (const auto& [name, command] : commands_) {
result.push_back({name, {}, command.help});
for (const auto& sub : command.subcommands) result.push_back({name + " " + sub.name, sub.args, sub.help});
}
return result;
}
+9 -2
View File
@@ -67,6 +67,10 @@ struct Limits {
// The game, as plugins see it (Environment, plugin_host.h, gives the host these).
using Vec3 = std::array<float, 3>;
// A console command a plugin added, or a subcommand it declared for one (Plugin::commands).
struct CommandInfo {
std::string name, args, help;
};
struct SkaterState {
Vec3 position{};
// Radians about world up, -pi to pi: 0 faces +Z, pi/2 faces +X, and a higher heading is turned
@@ -254,7 +258,9 @@ public:
std::size_t memory() const noexcept { return memory_; }
double tick_ms() const noexcept { return last_window_ms_; }
// Its console commands and their help.
std::vector<std::pair<std::string, std::string>> commands() const;
// Its console commands, and the subcommands each declared ("prop random", run by "prop"'s
// function): the name, its arguments as help shows them, and its help.
std::vector<CommandInfo> commands() const;
bool has(std::string_view permission) const { return manifest_.permissions.contains(permission); }
// Over a limit or stopped: the API refuses every call until the VM is closed.
bool halted() const noexcept { return halt_ != Halt::none || memory_exceeded_; }
@@ -268,7 +274,8 @@ private:
friend struct ServicesApi;
friend struct KitApi;
friend struct CaptureApi;
struct Command { std::string help; int function{}; };
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{}; };
using Clock = std::chrono::steady_clock;
enum class Halt { none, time, memory, stopped };
+18 -3
View File
@@ -14,6 +14,7 @@ struct State {
std::atomic<bool> ready{};
std::mutex mutex;
std::set<std::string, std::less<>> owners; // client thread
std::set<std::string, std::less<>> hiders; // client thread: plugins hiding the local skater
bool hooked{}; // client thread
std::atomic<std::uint64_t> drawn_at{}; // render thread -> client tick
};
@@ -42,18 +43,32 @@ void prepare(std::string_view owner, bool on) {
s.owners.erase(found);
}
}
bool hide(std::string_view owner, bool on) {
auto& s = state();
std::lock_guard lock(s.mutex);
if (!on) {
if (const auto found = s.hiders.find(owner); found != s.hiders.end()) s.hiders.erase(found);
return true;
}
if (!s.ready.load(std::memory_order_acquire)) return false;
s.hiders.emplace(owner);
return true;
}
void tick() noexcept {
auto& s = state();
if (!s.ready.load(std::memory_order_acquire)) return;
try {
bool wanted{};
bool xray{}, hiding{};
{
std::lock_guard lock(s.mutex);
wanted = !s.owners.empty();
xray = !s.owners.empty();
hiding = !s.hiders.empty();
}
const bool drawn = GetTickCount64() - s.drawn_at.load(std::memory_order_acquire) < follow_ms;
follow_render(wanted && drawn);
follow_render((xray && drawn) || hiding);
hide_skater(hiding);
const bool wanted = xray || hiding;
if (wanted != s.hooked && hook_render(wanted)) s.hooked = wanted;
} catch (...) {
}
+3
View File
@@ -33,4 +33,7 @@ struct Pose {
};
// Render thread, as a plugin draws it: false while there is no picture of the skater.
bool latest(Pose& pose);
// Client thread: the local skater not drawn on this screen while any plugin asks (reskate.skater.hide),
// through the same renderer hook. False when the renderer's hooks are unavailable.
bool hide(std::string_view owner, bool on);
}
+23 -1
View File
@@ -34,7 +34,7 @@ struct State {
RenderView view_original{};
std::array<std::atomic<std::uint32_t>, build::render_modes> indices{no_index, no_index};
std::atomic<std::uint64_t> indices_until{};
std::atomic<bool> found_logged{};
std::atomic<bool> found_logged{}, hiding{}, hide_logged{};
std::mutex lock; // guards the picture's parts
std::vector<game::LinearTransform> skin;
std::uint64_t skin_at{};
@@ -76,6 +76,21 @@ std::uint32_t render_index(std::uintptr_t base, std::uintptr_t entity, std::size
return handle >= build::first_handle ? handle - build::first_handle : no_index;
}
// One of the local skater's render objects (riding or walking), while the client tick follows them.
bool local_object(State& s, std::uintptr_t object) noexcept {
std::uint32_t index{};
return GetTickCount64() <= s.indices_until.load(std::memory_order_acquire) && pointer_at(object) == s.base + build::render_object_vtable &&
memory::peek(object + build::render_index_offset, index) &&
(index == s.indices[0].load(std::memory_order_acquire) || index == s.indices[1].load(std::memory_order_acquire));
}
bool clear_byte(std::uintptr_t address) noexcept {
__try {
*reinterpret_cast<volatile std::uint8_t*>(address) = 0;
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
// Render threads, after each skinned draw packet: the local skater's visible one, as skinning
// matrices in the world.
void observe_packet(std::uintptr_t object, std::uintptr_t packet) {
@@ -116,6 +131,12 @@ void draw_packet_hook(std::uintptr_t object, std::uintptr_t packet) {
s.draw_original(object, packet);
LastError error;
try {
// Hidden on this screen (reskate.skater.hide): the packet goes out not visible.
if (s.hiding.load(std::memory_order_acquire) && packet && local_object(s, object)) {
if (clear_byte(packet + build::packet_visible_offset) && !s.hide_logged.exchange(true))
logging::log(logging::Level::info, logging::Channel::graphics, "Skater hide: the local skater's draw packets go out not visible.");
return;
}
observe_packet(object, packet);
} catch (...) { /* A lost picture is never worth the renderer. */ }
}
@@ -249,6 +270,7 @@ void follow_render(bool wanted) noexcept {
s.indices_until.store(GetTickCount64() + lease_ms, std::memory_order_release);
}
void hide_skater(bool hidden) noexcept { state().hiding.store(hidden, std::memory_order_release); }
bool latest_picture(Picture& picture) noexcept {
auto& s = state();
const auto now = GetTickCount64();
+3
View File
@@ -25,4 +25,7 @@ struct Picture {
};
// Render thread: the latest picture's, while the renderer draws the local skater.
bool latest_picture(Picture& picture) noexcept;
// Client tick: while true (and followed, follow_render), the local skater's draw packets go out
// not visible, so this screen does not draw it; while hidden there is no picture.
void hide_skater(bool hidden) noexcept;
}
+6
View File
@@ -342,6 +342,12 @@ struct Model {
KeyframeSmoothingModel keyframe_smoothing;
ObjectPersistenceModel object_persistence;
ParkEditorModel editor;
// The console commands plugins added (Extension/Plugins/plugin_runtime.h command_hints), for
// the console's lookup beside ReSkate's own.
struct PluginCommand {
std::string name, args, help, plugin; // name: one to four words
};
std::vector<PluginCommand> plugin_commands;
float menu_scale = default_menu_scale;
float menu_scale_fullscreen = default_menu_scale; // the full screen menu's own size (`ui scalefull`)
// The ReSkate menu fills the screen, made for a controller, instead of a window (`ui fullscreen`).
+27
View File
@@ -41,6 +41,33 @@ std::vector<std::string> console_completion_candidates(const std::vector<Console
std::vector<std::string> words, result;
for (const auto& token : tokens) words.push_back(token.text);
s.console_suggestion_rows = dingosdk::console::game_commands().complete(words, s.model);
// Plugins' commands beside ReSkate's own, word by word as ReSkate's: after "prop " come the
// subcommands the plugin declared for "prop".
if (!words.empty()) {
const auto same = [](std::string_view a, std::string_view b) {
return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
return std::tolower(static_cast<unsigned char>(x)) == std::tolower(static_cast<unsigned char>(y));
});
};
const auto& prefix = words.back();
for (const auto& command : s.model.plugin_commands) {
const auto path = dingosdk::parse_console_command(command.name).arguments;
if (path.size() < words.size()) continue;
bool matches = true;
for (std::size_t i = 0; i + 1 < words.size() && matches; ++i) matches = same(words[i], path[i]);
const auto& next = path[words.size() - 1];
if (!matches || next.size() < prefix.size() || !same(std::string_view(next).substr(0, prefix.size()), prefix)) continue;
if (std::any_of(s.console_suggestion_rows.begin(), s.console_suggestion_rows.end(), [&](const auto& row) { return same(row.text, next); }))
continue;
const bool last = path.size() == words.size();
dingosdk::console::Suggestion row;
row.text = row.name = next;
if (last) row.name = command.name;
row.description = (last ? command.help : std::string("Plugin command")) + " (" + command.plugin + ")";
row.usage = last ? command.name + (command.args.empty() ? "" : " " + command.args) : next + " ...";
s.console_suggestion_rows.push_back(std::move(row));
}
}
for (const auto& row : s.console_suggestion_rows) result.push_back(row.text);
return result;
}
+293 -4
View File
@@ -17,6 +17,8 @@
#include <format>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <stdexcept>
#include <vector>
@@ -40,12 +42,17 @@ struct Wanted {
Matrix matrix{};
bool hidden{};
std::optional<Attach> attach;
bool solid{true};
};
struct Live {
std::uintptr_t entity{}, owner{}, context{};
std::string asset;
Matrix matrix{};
std::uint64_t moved{};
// Its physics bodies once found, whether they are ghosted, and when to look for them again.
std::vector<Body> bodies;
bool looked{}, ghosted{};
std::uint64_t made{}, next_look{};
};
struct Asset {
std::uintptr_t asset{};
@@ -67,6 +74,48 @@ struct State {
};
State& state() { static auto* s = new State; return *s; }
std::atomic<JointSource> joint_source{};
Physics& physics() { static auto* value = new Physics; return *value; }
std::mutex test_mutex;
std::string test_request; // under test_mutex
std::atomic<bool> physics_set{};
// An object's physics bodies: in its entity and what it points to (two levels, objects with a
// vtable in the game image only), the physics world's address followed by a body the world says is
// valid. All of them must have one owner (a world object's own), and be few.
std::vector<Body> find_bodies(std::uintptr_t base, std::uintptr_t entity, std::uintptr_t world) {
const auto& p = physics();
std::vector<Body> found;
std::vector<std::pair<std::uintptr_t, unsigned>> queue{{entity, 0}};
std::set<std::uintptr_t> seen{entity};
const auto game_object = [&](std::uintptr_t at) {
const auto vtable = ptr(at);
return vtable >= base && vtable < base + 0x10000000;
};
for (std::size_t next = 0; next < queue.size() && queue.size() < 512; ++next) {
const auto [node, depth] = queue[next];
std::array<std::uintptr_t, 0x80> words{};
if (!memory::peek(node, words)) continue;
for (std::size_t i = 0; i < words.size(); ++i) {
const auto value = words[i];
if (value == world && i + 1 < words.size()) {
const Body body{world, words[i + 1]};
if (static_cast<std::uint32_t>(body[1]) < (1u << 24) && p.valid(body) &&
std::find(found.begin(), found.end(), body) == found.end())
found.push_back(body);
continue;
}
if (depth < 2 && value > 0x10000 && value < 0x7ff000000000 && !seen.contains(value) && game_object(value)) {
seen.insert(value);
queue.emplace_back(value, depth + 1);
}
}
}
if (found.empty() || found.size() > 16) return {};
const auto owner = p.owner(found.front());
for (const auto& body : found)
if (p.owner(body) != owner) return {};
return found;
}
// `offset` (in a joint's frame) placed on the joint: each row taken through the joint's axes.
Matrix on_joint(const Matrix& joint, const Matrix& offset) {
@@ -81,6 +130,17 @@ Matrix on_joint(const Matrix& joint, const Matrix& offset) {
m[15] = 1.0f;
return m;
}
// The joint stood level at its position: up is world up, and forward its own forward axis laid flat
// (its up axis laid flat when the forward axis points up or down). Rows right (fz, 0, -fx), up,
// forward, as the skater's own frame is (heading 0 faces +Z, right +X).
Matrix level(const Matrix& joint) {
float fx = joint[8], fz = joint[10];
if (std::hypot(fx, fz) < 0.2f * std::hypot(joint[8], joint[9], joint[10])) fx = joint[4], fz = joint[6];
const float length = std::hypot(fx, fz);
if (!(length > 1e-6f)) fx = 0, fz = 1;
else fx /= length, fz /= length;
return {fz, 0, -fx, 0, 0, 1, 0, 0, fx, 0, fz, 0, joint[12], joint[13], joint[14], 1};
}
void note(State& s, std::string issue) {
if (s.waiting == issue) return;
@@ -181,12 +241,149 @@ std::uintptr_t resolve(std::uintptr_t base, Asset& a, const std::string& name, s
}
} // namespace
void set_joint_source(JointSource source) noexcept { joint_source.store(source, std::memory_order_release); }
// ---- the blueprints loaded -------------------------------------------------------------------
// Each asset domain (engine::domain_owners) keeps its named assets in a registry (domain + 0x48),
// with registries of its own at +0x58..+0x60 (and theirs): an EASTL hash map of the name's hash to
// an entry (buckets at +0xf8, their count at +0x100, a node {hash, entry, next}), behind the
// critical section at +0x90 (the game's own lookup, 0x41b5800 -> 0x3db78d0 / 0x3db7f60). An entry
// starts with the tagged asset (bits 1 and 2 are tags; bit 2 set: one more pointer to follow,
// 0x3db9060), then the asset's GUID and the name's hash. An asset's name is at +0x18; it is taken
// only when it hashes to the node's key, as the game hashes names (5381, x33, xor, A-Z folded).
// Read in a running game on 2026-10-21: 57 registries, 2930 world object blueprints.
namespace {
std::uint64_t asset_name_hash(std::string_view name) {
std::uint64_t hash = 0x1505;
for (const unsigned char c : name) hash = (std::uint64_t{c} + (c >= 'A' && c <= 'Z' ? 0x20 : 0)) ^ (hash * 33);
return hash;
}
bool prefixed(std::string_view name, std::string_view prefix) {
if (name.size() < prefix.size()) return false;
for (std::size_t i = 0; i < prefix.size(); ++i) {
const auto a = static_cast<unsigned char>(name[i]), b = static_cast<unsigned char>(prefix[i]);
if ((a >= 'A' && a <= 'Z' ? a + 32 : a) != (b >= 'A' && b <= 'Z' ? b + 32 : b)) return false;
}
return true;
}
std::optional<std::string> asset_name(std::uintptr_t asset, std::uint64_t key) {
const auto at = ptr(asset + 0x18);
if (!at) return std::nullopt;
std::array<char, 257> text{};
if (!memory::peek_bytes(at, text.data(), text.size() - 1)) {
// A short name near the end of readable memory: read it in smaller pieces.
std::size_t length = 0;
for (; length < 256 && memory::peek(at + length, text[length]) && text[length]; ++length) {}
text[length] = 0;
}
const std::string name(text.data(), strnlen(text.data(), 256));
if (name.empty() || name.size() >= 256 || asset_name_hash(name) != key) return std::nullopt;
return name;
}
// The mesh box of a world object blueprint's asset (Engine/Game/Build/20260929/skater_entities.h).
std::optional<Box> asset_bounds(std::uintptr_t base, std::uintptr_t asset) {
const auto data = asset + e::object_data_offset;
if (ptr(data) != base + e::object_data_vtable) return std::nullopt;
const auto mesh = ptr(data + e::object_data_mesh_offset);
if (!mesh || ptr(mesh) != base + e::mesh_asset_vtable) return std::nullopt;
const auto loaded = ptr(mesh + e::mesh_asset_loaded_offset);
if (!loaded || ptr(loaded) != base + e::loaded_mesh_vtable) return std::nullopt;
const auto at = ptr(loaded + e::loaded_mesh_bounds_offset);
std::array<float, 8> v{};
if (!at || !memory::peek(at, v)) return std::nullopt;
Box box{{{v[0], v[1], v[2]}, {v[4], v[5], v[6]}}};
for (int i = 0; i < 3; ++i)
if (!std::isfinite(box[0][i]) || !std::isfinite(box[1][i]) || box[1][i] < box[0][i] || box[1][i] - box[0][i] > 2000) return std::nullopt;
return box;
}
void walk_registry(std::uintptr_t base, std::uintptr_t registry, std::string_view prefix, bool meshes_only, std::set<std::string>& found,
std::set<std::uintptr_t>& walked, unsigned depth = 0) {
if (!registry || depth > 16 || walked.size() >= 4096 || !walked.insert(registry).second) return;
// Its own registries first, outside its lock (the game takes each one's lock on its own).
if (const auto first = ptr(registry + 0x58), last = ptr(registry + 0x60); first && last >= first && last - first <= 8 * 4096)
for (auto at = first; at < last; at += 8)
if (const auto child = ptr(at)) walk_registry(base, child, prefix, meshes_only, found, walked, depth + 1);
const auto buckets = ptr(registry + 0xf8);
const auto count = get<std::uint32_t>(registry + 0x100);
if (!buckets || !count || count > (1u << 22)) return;
auto* section = reinterpret_cast<CRITICAL_SECTION*>(registry + 0x90);
EnterCriticalSection(section);
struct Leave { CRITICAL_SECTION* section; ~Leave() { LeaveCriticalSection(section); } } leave{section};
const auto sentinel = ptr(buckets + std::uintptr_t{count} * 8);
const auto read = [](auto at) { return ptr(at); };
std::size_t steps = 0;
for (std::uint32_t bucket = 0; bucket < count; ++bucket) {
for (auto node = ptr(buckets + std::uintptr_t{bucket} * 8); node && node != sentinel && ++steps < (1u << 22); node = ptr(node + 16)) {
const auto entry = ptr(node + 8);
const auto tagged = entry ? ptr(entry) : 0;
if (!tagged) continue;
auto asset = tagged & ~std::uintptr_t{6};
if (tagged & 4) asset = ptr(asset);
if (!detail::object_blueprint(asset, base, read) || (meshes_only && !asset_bounds(base, asset))) continue;
if (auto name = asset_name(asset, get<std::uint64_t>(node)); name && prefixed(*name, prefix)) found.insert(std::move(*name));
}
}
}
struct Listing {
std::mutex mutex;
std::uint64_t at{};
std::string prefix;
bool meshes_only{};
std::vector<std::string> names;
};
Listing& listing() { static auto* value = new Listing; return *value; }
} // namespace
std::string set(std::string_view owner, std::uint32_t id, std::string asset, const Matrix& matrix, bool hidden, std::optional<Attach> attach) {
std::vector<std::string> blueprints(std::uintptr_t base, std::string_view prefix, std::size_t most, bool meshes_only) {
auto& l = listing();
std::lock_guard lock(l.mutex);
const auto now = GetTickCount64();
if (!l.at || now >= l.at + 2000 || l.prefix != prefix || l.meshes_only != meshes_only) {
std::set<std::string> found;
std::set<std::uintptr_t> walked;
try {
for (std::uint16_t domain = 0; domain < 0xbbf && found.size() < 65536; ++domain) {
const auto owner = ptr(base + addr::engine::domain_owners, std::uintptr_t(domain) * 8);
if (const auto registry = owner ? ptr(owner + 0x48) : 0) walk_registry(base, registry, prefix, meshes_only, found, walked);
}
} catch (...) {
}
l.names.assign(found.begin(), found.end());
l.prefix = std::string(prefix);
l.meshes_only = meshes_only;
l.at = now;
}
return {l.names.begin(), l.names.begin() + static_cast<std::ptrdiff_t>(std::min(most, l.names.size()))};
}
std::optional<Box> blueprint_bounds(std::uintptr_t base, std::string_view blueprint) {
const auto find = game::native_data().find_asset;
if (!find || blueprint.empty() || blueprint.size() > 200) return std::nullopt;
const std::string name(blueprint);
const auto read = [](auto at) { return ptr(at); };
for (std::uint16_t domain = 0; domain < 0xbbf; ++domain) {
if (!ptr(base + addr::engine::domain_owners, std::uintptr_t(domain) * 8)) continue;
if (const auto asset = find(domain, name.c_str()); detail::object_blueprint(asset, base, read)) return asset_bounds(base, asset);
}
return std::nullopt;
}
void set_joint_source(JointSource source) noexcept { joint_source.store(source, std::memory_order_release); }
void test_bodies(std::string name) {
std::lock_guard lock(test_mutex);
test_request = std::move(name);
}
void set_physics(const Physics& p) noexcept {
physics() = p;
physics_set.store(true, std::memory_order_release);
}
std::string set(std::string_view owner, std::uint32_t id, std::string asset, const Matrix& matrix, bool hidden, std::optional<Attach> attach,
bool solid) {
if (asset.empty() || asset.size() > 200) return "an object's blueprint is a name of 1 to 200 characters";
if (!finite(matrix)) return "an object's transform must be finite, within 100 km, each axis at most 200 long";
if (attach && attach->joint >= 395) return "a skater's joints are 0 to 394";
if (attach && !(attach->ground >= 0 && attach->ground <= 50)) return "an object finds the ground at most 50 m below its joint";
if ((!solid || (attach && attach->ground > 0)) && !physics_set.load(std::memory_order_acquire))
return "objects that are not solid or stand on the ground are unavailable for this game build";
auto& s = state();
std::lock_guard lock(s.mutex);
const Key key{std::string(owner), id};
@@ -195,7 +392,7 @@ std::string set(std::string_view owner, std::uint32_t id, std::string asset, con
if (static_cast<std::size_t>(mine) >= max_per_owner) return std::format("at most {} objects", max_per_owner);
if (s.wanted.size() >= max_total) return "the world holds as many objects as ReSkate keeps";
}
s.wanted[key] = {std::move(asset), matrix, hidden, attach};
s.wanted[key] = {std::move(asset), matrix, hidden, attach, solid};
return {};
}
void remove(std::string_view owner, std::uint32_t id) {
@@ -236,12 +433,77 @@ std::size_t count(std::string_view owner) {
return static_cast<std::size_t>(std::count_if(s.wanted.begin(), s.wanted.end(), [&](const auto& w) { return w.first.first == owner; }));
}
std::vector<std::uintptr_t> live_entities() {
auto& s = state();
std::lock_guard lock(s.mutex);
std::vector<std::uintptr_t> out;
for (const auto& [key, l] : s.live) out.push_back(l.entity);
return out;
}
void entity_destroyed(std::uintptr_t entity) noexcept {
auto& s = state();
std::lock_guard lock(s.mutex);
std::erase_if(s.live, [&](const auto& l) { return l.second.entity == entity; });
}
namespace {
// An object's bodies, looked for while it is new (its physics may come a few frames after it), for
// the floor below it and for being made not solid; then ghosted or made solid as it is wanted.
void bodies_for(std::uintptr_t base, const Key& key, Live& l, const Wanted& w, std::uint64_t now) {
auto& p = physics();
if (!physics_set.load(std::memory_order_acquire) || !p.world) return;
const bool needed = !w.solid || (w.attach && w.attach->ground > 0);
if (!needed && !l.ghosted) return;
if (l.bodies.empty() && !l.looked && now >= l.next_look) {
l.next_look = now + 250;
if (const auto world = p.world()) l.bodies = find_bodies(base, l.entity, world);
// Not kept in the entity where they were looked for: rays into the object's own box, the
// first body they meet, and the rest of that body's owner's.
if (l.bodies.empty() && p.hit && p.owned)
if (const auto box = blueprint_bounds(base, l.asset)) {
const auto& m = l.matrix;
const auto at = [&](float x, float y, float z) {
return std::array<float, 3>{m[12] + m[0] * x + m[4] * y + m[8] * z, m[13] + m[1] * x + m[5] * y + m[9] * z,
m[14] + m[2] * x + m[6] * y + m[10] * z};
};
const auto& lo = (*box)[0];
const auto& hi = (*box)[1];
const float cx = (lo[0] + hi[0]) / 2, cy = (lo[1] + hi[1]) / 2, cz = (lo[2] + hi[2]) / 2;
const std::array<std::pair<std::array<float, 3>, std::array<float, 3>>, 3> rays{{
{at(cx, hi[1] + 0.5f, cz), at(cx, lo[1] + 0.05f, cz)}, // short of the floor it stands on
{at(lo[0] - 0.5f, cy, cz), at(hi[0], cy, cz)},
{at(cx, cy, lo[2] - 0.5f), at(cx, cy, hi[2])},
}};
for (const auto& [from, to] : rays)
if (const auto body = p.hit(from, to)) {
l.bodies = p.owned(*body, 16);
if (!l.bodies.empty()) break;
}
}
if (!l.bodies.empty() || now >= l.made + 5000) {
l.looked = true;
logging::log(logging::Level::info, logging::Channel::runtime, "World objects: {} {} has {} physics bod{}.", key.first, l.asset,
l.bodies.size(), l.bodies.size() == 1 ? "y" : "ies");
}
}
const bool ghost = !w.solid;
if (ghost != l.ghosted && !l.bodies.empty()) {
bool all = true;
for (const auto& body : l.bodies) all = p.ghost(body, ghost) && all;
if (all) l.ghosted = ghost;
}
auto& s = state();
std::lock_guard lock(s.mutex);
if (const auto at = s.live.find(key); at != s.live.end() && at->second.entity == l.entity) {
at->second.bodies = l.bodies;
at->second.looked = l.looked;
at->second.ghosted = l.ghosted;
at->second.next_look = l.next_look;
}
}
} // namespace
void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept {
auto& s = state();
try {
@@ -295,7 +557,16 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept {
for (auto& [key, w] : wanted) {
if (!w.attach || w.hidden) continue;
if (const auto at = source ? source(w.attach->player, w.attach->joint) : std::nullopt; at && finite(*at)) {
w.matrix = on_joint(*at, w.matrix);
const Matrix held{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, (*at)[12], (*at)[13], (*at)[14], 1};
const float height = w.matrix[13];
w.matrix = on_joint(!w.attach->turn ? held : w.attach->upright ? level(*at) : *at, w.matrix);
// On the floor within reach below the joint (the object's own bodies are not floor).
if (w.attach->ground > 0 && physics_set.load(std::memory_order_acquire) && physics().floor) {
const auto l = live.find(key);
static const std::vector<Body> none;
if (const auto floor = physics().floor({(*at)[12], (*at)[13], (*at)[14]}, w.attach->ground, l != live.end() ? l->second.bodies : none))
w.matrix[13] = *floor + height;
}
s.lost.erase(key);
} else {
unplaced.push_back(key);
@@ -332,6 +603,7 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept {
for (const auto& [key, w] : wanted) {
if (w.hidden || std::find(unplaced.begin(), unplaced.end(), key) != unplaced.end()) continue;
if (const auto l = live.find(key); l != live.end()) {
bodies_for(base, key, l->second, w, now);
// Move a resident object in place: recreating restarts its effects and makes it jump.
// One on a joint follows it every frame.
if (l->second.matrix != w.matrix && (w.attach || now >= l->second.moved + 16)) {
@@ -350,11 +622,28 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept {
made.asset = w.asset;
made.matrix = w.matrix;
made.moved = now;
made.made = now;
++created;
std::lock_guard lock(s.mutex);
s.live[key] = std::move(made);
}
if (created) note(s, std::format("{} object(s) created", created));
// Test only: a per-body setter on every live object's bodies.
std::string test;
{
std::lock_guard lock(test_mutex);
test.swap(test_request);
}
if (!test.empty() && physics_set.load(std::memory_order_acquire) && physics().test) {
unsigned ran{}, bodies{};
std::lock_guard lock(s.mutex);
for (const auto& [key, l] : s.live)
for (const auto& body : l.bodies) {
++bodies;
if (physics().test(test, body)) ++ran;
}
logging::log(logging::Level::info, logging::Channel::runtime, "World objects: {} ran on {} of {} bodies.", test, ran, bodies);
}
} catch (const std::exception& error) {
if (s.error != error.what()) {
s.error = error.what();
+43 -2
View File
@@ -4,6 +4,7 @@
#include <optional>
#include <string>
#include <string_view>
#include <vector>
// Native world objects that ReSkate keeps for its plugins: one entity of a world object blueprint
// (a prop, a gate's arch or bolt, a traffic cone) at a full 4x4 transform, as SmokeyDawg58's fork
@@ -19,11 +20,41 @@ enum class Status { none, waiting, loading, missing, ready, hidden };
// An object kept on a joint of a skater (of the 395 the animation uses): `player` 0 for the local
// skater, else another player in the session. Its matrix is then in the joint's frame, and it moves
// with the joint every frame; while the joint is not known it waits, and after a second without it a
// shown object is taken away until the joint comes back.
// shown object is taken away until the joint comes back. `upright`: the object stands level at the
// joint, turned only about world up by the joint's heading, rather than leaning with the joint.
// `turn` false: the object follows the joint's position only; its matrix's rotation is the world's,
// and its position an offset in world axes. `ground` (metres, 0 none): the object's height is the
// floor's within that far below the joint, plus its matrix's height, when there is one (the object's
// own bodies are not floor); else the joint's, as without it.
struct Attach {
std::uint64_t player{};
std::uint16_t joint{};
bool upright{};
bool turn{true};
float ground{};
};
// The client physics world, as the runtime gives it (Extension/Objects/ParkEditor/world_pick.cpp;
// Engine/Game/Build/20260929/physics_bodies.h): a body is {world, index | generation << 32}. A
// world object made not `solid` has its bodies ghosted (skaters go through them), found in its
// entity, a few levels down, as the world followed by a body the world says is valid.
using Body = std::array<std::uint64_t, 2>;
struct Physics {
std::uintptr_t (*world)() noexcept{};
bool (*valid)(const Body&) noexcept{};
std::uint32_t (*owner)(const Body&) noexcept{};
bool (*ghost)(const Body&, bool) noexcept{};
// The floor's height under `from`, down to `depth` metres below it, ignoring `ignored`.
std::optional<float> (*floor)(const std::array<float, 3>& from, float depth, const std::vector<Body>& ignored) noexcept{};
// The body a ray from `from` to `to` meets first, and every body of that body's owner (empty
// when it has more than `most`: the level's, or another shared owner's).
std::optional<Body> (*hit)(const std::array<float, 3>& from, const std::array<float, 3>& to) noexcept{};
std::vector<Body> (*owned)(const Body& body, std::size_t most) noexcept{};
// Test only (objbody): one of the store's per-body setters by name, on a body.
bool (*test)(std::string_view name, const Body& body) noexcept{};
};
// Test only: the next client tick runs the named per-body setter on every live object's bodies.
void test_bodies(std::string name);
void set_physics(const Physics& physics) noexcept;
// Where a skater's joint is in the world now, or empty (set by the runtime: the local skater's pose
// and the session's drawn poses). Called on the client thread.
using JointSource = std::optional<Matrix> (*)(std::uint64_t player, std::uint16_t joint) noexcept;
@@ -31,13 +62,23 @@ void set_joint_source(JointSource source) noexcept;
// An object of `owner` (a plugin's id). False and why when it cannot be kept.
std::string set(std::string_view owner, std::uint32_t id, std::string asset, const Matrix& matrix, bool hidden,
std::optional<Attach> attach = std::nullopt);
std::optional<Attach> attach = std::nullopt, bool solid = true);
void remove(std::string_view owner, std::uint32_t id);
void clear(std::string_view owner);
Status status(std::string_view owner, std::uint32_t id);
// Every shown object of the owner exists in the world.
bool ready(std::string_view owner);
// The names of the world object blueprints the game has loaded now (what `set` can make), those
// starting with `prefix` (ASCII case ignored), sorted, at most `most`. Read from every asset
// domain's named-asset table; kept for two seconds.
std::vector<std::string> blueprints(std::uintptr_t base, std::string_view prefix, std::size_t most, bool meshes_only = false);
// A world object blueprint's mesh box in its own frame (min, max), when it has a mesh and the mesh
// is loaded; empty for one that shows nothing, or that is not loaded.
using Box = std::array<std::array<float, 3>, 2>;
std::optional<Box> blueprint_bounds(std::uintptr_t base, std::string_view blueprint);
std::size_t count(std::string_view owner);
// The native entities of every owner's objects in the world now (client thread).
std::vector<std::uintptr_t> live_entities();
// Runtime glue (client_tick.cpp, runtime_init.cpp, native_skater.cpp's destroy hook).
void tick(std::uintptr_t base, std::uintptr_t client, bool ready) noexcept;
+3
View File
@@ -1333,6 +1333,9 @@ void update_model(std::uintptr_t client, TickState& frame) {
dingosdk::update_local_graphics_controls();
model.graphics = dingosdk::local_profile_graphics_controls();
model.object_persistence = dingosdk::local_profile_object_persistence();
model.plugin_commands.clear();
for (auto& hint : dingosdk::plugins::command_hints())
model.plugin_commands.push_back({std::move(hint.name), std::move(hint.args), std::move(hint.help), std::move(hint.plugin)});
// The park list is a disk walk of the Mods folder: only while the menu or the editor shows it.
dingosdk::set_park_mod_list_visible(r.debug_model.park_editor ||
GetTickCount64() < r.named_settings_wanted_until.load(std::memory_order_relaxed));
+3 -1
View File
@@ -232,7 +232,9 @@ std::optional<std::string> ServerPlugins::command(std::string_view line) {
if (verb == "commands") {
std::string text;
for (const auto &plugin : s.plugins.status())
for (const auto &[name, help] : plugin.commands) text += (text.empty() ? "" : "\n") + std::format("{} - {} ({})", name, help, plugin.id);
for (const auto &command : plugin.commands)
text += (text.empty() ? "" : "\n") + std::format("{}{}{} - {} ({})", command.name, command.args.empty() ? "" : " ", command.args,
command.help, plugin.id);
return text.empty() ? std::string("No plugin has a console command.") : text;
}
if (!verb.empty() && verb != "list") return std::string("plugin list|commands|reload <id>|off <id>|on <id>");
+1
View File
@@ -115,6 +115,7 @@ add_library(dingosdk_runtime SHARED
Extension/Objects/ParkEditor/park_editor_runtime.cpp
Extension/Objects/ParkEditor/park_editor_preview.cpp
Extension/Objects/ParkEditor/park_editor_selection.cpp
Extension/Objects/ParkEditor/world_pick.cpp
Extension/Objects/ParkEditor/park_editor_lobby.cpp
Extension/Objects/ParkEditor/park_editor_operations.cpp
Extension/Objects/network_object_runtime.cpp