diff --git a/Engine/Game/Build/20260929/physics_bodies.h b/Engine/Game/Build/20260929/physics_bodies.h new file mode 100644 index 00000000..93c25b6f --- /dev/null +++ b/Engine/Game/Build/20260929/physics_bodies.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +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 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 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 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 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}; +} diff --git a/Engine/Game/Build/20260929/skater_entities.h b/Engine/Game/Build/20260929/skater_entities.h index 1843ed74..e7c4a5ab 100644 --- a/Engine/Game/Build/20260929/skater_entities.h +++ b/Engine/Game/Build/20260929/skater_entities.h @@ -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 skater_component_base_vtables{0x61705e8, 0x6170650}; // ClientSkaterSource::applyAppearance. It enables the customization component and requests diff --git a/Extension/Objects/ParkEditor/park_editor_runtime.h b/Extension/Objects/ParkEditor/park_editor_runtime.h index 092313ea..dbed24ad 100644 --- a/Extension/Objects/ParkEditor/park_editor_runtime.h +++ b/Extension/Objects/ParkEditor/park_editor_runtime.h @@ -1,6 +1,9 @@ #pragma once #include "Engine/Game/World/park_editor.h" #include "Extension/Objects/object_placements.h" +#include +#include +#include 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 at{}; + std::uint64_t entity{}; + std::optional> pose; + std::string blueprint; +}; +std::optional local_world_pick(const std::array &from, const std::array &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 at{}; diff --git a/Extension/Objects/ParkEditor/world_pick.cpp b/Extension/Objects/ParkEditor/world_pick.cpp new file mode 100644 index 00000000..c4f9ff36 --- /dev/null +++ b/Extension/Objects/ParkEditor/world_pick.cpp @@ -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 +#include +#include +#include + +// 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 entity{}, root{}; + last_hit = {}; + if (!surface_body(world, index, data, body)) return 0; + last_hit.body = true; + last_hit.world = static_cast(body[0]); + last_hit.index = static_cast(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(c) < 0x20 || static_cast(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 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 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 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 reference{}; + alignas(16) std::array 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, 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 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 local_world_pick(const std::array &from, const std::array &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 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 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(last_hit.value)); + } catch (...) { + } + } + if (root) { + try { + log_plugin_object_ids(last_hit.entity, root); + } catch (...) { + } + alignas(16) std::array reference{}; + alignas(16) std::array query{}; + alignas(16) std::array 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 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(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(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 &prefix) { + std::array 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 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(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 body{b[0], b[1]}; + reinterpret_cast(base + (ghost ? pb::main_ghost : pb::main_solid))(body.data()); + reinterpret_cast(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(b[1]), + ghost ? "ghosted" : "made solid"); + return true; +} +std::optional physics_floor(const std::array &from, float depth, const std::vector &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 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 physics_hit(const std::array &from, const std::array &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 physics_owned(const world_entities::Body &b, std::size_t most) noexcept { + try { + const auto world = static_cast(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 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((end - begin) / 4); + const auto index = static_cast(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 owners(piece); + std::vector 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 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 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 body{b[0], b[1]}; + reinterpret_cast(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}); + }); +} +} diff --git a/Extension/Objects/console_commands.cpp b/Extension/Objects/console_commands.cpp index 4dca6f92..4e5b73d0 100644 --- a/Extension/Objects/console_commands.cpp +++ b/Extension/Objects/console_commands.cpp @@ -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 ®istry) { + // 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(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"}; diff --git a/Extension/Plugins/Test/plugin_tests.cpp b/Extension/Plugins/Test/plugin_tests.cpp index 2babdd90..7fc6c282 100644 --- a/Extension/Plugins/Test/plugin_tests.cpp +++ b/Extension/Plugins/Test/plugin_tests.cpp @@ -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>> entities; std::string entity_set(std::string_view plugin, std::uint32_t id, std::string_view asset, const std::array& m, bool hidden, - std::optional attach) override { + std::optional 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> entity_bounds(std::string_view blueprint) override { + if (blueprint != "World/Props/TrashCan_01") return std::nullopt; + return std::array{Vec3{-0.3f, 0, -0.3f}, Vec3{0.3f, 0.9f, 0.3f}}; + } + std::vector entity_blueprints(std::string_view prefix, bool meshes_only) override { + std::vector 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 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{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); diff --git a/Extension/Plugins/plugin_api.cpp b/Extension/Plugins/plugin_api.cpp index ad1a46d4..609d5398 100644 --- a/Extension/Plugins/plugin_api.cpp +++ b/Extension/Plugins/plugin_api.cpp @@ -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 subcommands; + if (!lua_isnoneornil(L, 4)) { + luaL_checktype(L, 4, LUA_TTABLE); + const int count = static_cast(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(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; } diff --git a/Extension/Plugins/plugin_api_world.cpp b/Extension/Plugins/plugin_api_world.cpp index fa4bdf95..bfa4e26d 100644 --- a/Extension/Plugins/plugin_api_world.cpp +++ b/Extension/Plugins/plugin_api_world.cpp @@ -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 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(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(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(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 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(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(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(joint); } static int push_joint(lua_State* L, const std::optional& 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}, diff --git a/Extension/Plugins/plugin_game.h b/Extension/Plugins/plugin_game.h index 77144712..1388066a 100644 --- a/Extension/Plugins/plugin_game.h +++ b/Extension/Plugins/plugin_game.h @@ -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&, bool, - std::optional /*attach*/) { + std::optional /*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 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> 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> matrix; + std::string blueprint; + }; + virtual std::optional 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 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 diff --git a/Extension/Plugins/plugin_game_runtime.cpp b/Extension/Plugins/plugin_game_runtime.cpp index 11735083..fe713b79 100644 --- a/Extension/Plugins/plugin_game_runtime.cpp +++ b/Extension/Plugins/plugin_game_runtime.cpp @@ -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& matrix, bool hidden, - std::optional attach) override { - if (!attach) return world_entities::set(plugin, id, std::string(asset), matrix, hidden); + std::optional 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(world_entities::status(plugin, id)); } bool entities_ready(std::string_view plugin) override { return world_entities::ready(plugin); } + std::vector entity_blueprints(std::string_view prefix, bool meshes_only) override { + return world_entities::blueprints(base_, prefix, 4096, meshes_only); + } + std::optional> entity_bounds(std::string_view blueprint) override { + const auto box = world_entities::blueprint_bounds(base_, blueprint); + if (!box) return std::nullopt; + return std::array{(*box)[0], (*box)[1]}; + } + std::optional 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, 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 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); diff --git a/Extension/Plugins/plugin_host.h b/Extension/Plugins/plugin_host.h index a15ab408..b301b3ee 100644 --- a/Extension/Plugins/plugin_host.h +++ b/Extension/Plugins/plugin_host.h @@ -74,7 +74,7 @@ struct Status { std::string reason; // why it is not running std::size_t memory{}; double tick_ms{}; - std::vector> commands; + std::vector commands; // with the subcommands they declared }; class Host final : private Services { diff --git a/Extension/Plugins/plugin_runtime.cpp b/Extension/Plugins/plugin_runtime.cpp index 919ba1e7..515a9ca1 100644 --- a/Extension/Plugins/plugin_runtime.cpp +++ b/Extension/Plugins/plugin_runtime.cpp @@ -454,9 +454,10 @@ std::string list_commands() { std::string text; const auto add = [&](const std::vector& 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 command_hints() { + static std::vector 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& 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; +} + // "@server" names a server copy (on this game while it hosts a lobby); a plain id, the player's copy. namespace { template std::string either(std::string_view id, Do&& act) { diff --git a/Extension/Plugins/plugin_runtime.h b/Extension/Plugins/plugin_runtime.h index 2fed0c83..fd807521 100644 --- a/Extension/Plugins/plugin_runtime.h +++ b/Extension/Plugins/plugin_runtime.h @@ -26,6 +26,12 @@ std::vector> 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 "@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 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); diff --git a/Extension/Plugins/plugin_vm.cpp b/Extension/Plugins/plugin_vm.cpp index b9eb0b41..fdbc0841 100644 --- a/Extension/Plugins/plugin_vm.cpp +++ b/Extension/Plugins/plugin_vm.cpp @@ -771,9 +771,12 @@ void Plugin::set_channel(std::string channel) { channel_hash_ = wire::channel_of(channel_); } -std::vector> Plugin::commands() const { - std::vector> result; - for (const auto& [name, command] : commands_) result.emplace_back(name, command.help); +std::vector Plugin::commands() const { + std::vector 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; } diff --git a/Extension/Plugins/plugin_vm.h b/Extension/Plugins/plugin_vm.h index 59bc5a83..47ba0c80 100644 --- a/Extension/Plugins/plugin_vm.h +++ b/Extension/Plugins/plugin_vm.h @@ -67,6 +67,10 @@ struct Limits { // The game, as plugins see it (Environment, plugin_host.h, gives the host these). using Vec3 = std::array; +// 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> 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 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 subcommands; }; struct Timer { double due{}, every{}; int function{}; }; using Clock = std::chrono::steady_clock; enum class Halt { none, time, memory, stopped }; diff --git a/Extension/Skater/xray.cpp b/Extension/Skater/xray.cpp index bda1d027..0df9add9 100644 --- a/Extension/Skater/xray.cpp +++ b/Extension/Skater/xray.cpp @@ -14,6 +14,7 @@ struct State { std::atomic ready{}; std::mutex mutex; std::set> owners; // client thread + std::set> hiders; // client thread: plugins hiding the local skater bool hooked{}; // client thread std::atomic 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 (...) { } diff --git a/Extension/Skater/xray.h b/Extension/Skater/xray.h index ac731678..cf7b203c 100644 --- a/Extension/Skater/xray.h +++ b/Extension/Skater/xray.h @@ -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); } diff --git a/Extension/Skater/xray_render.cpp b/Extension/Skater/xray_render.cpp index 9f84daed..67eeb6d8 100644 --- a/Extension/Skater/xray_render.cpp +++ b/Extension/Skater/xray_render.cpp @@ -34,7 +34,7 @@ struct State { RenderView view_original{}; std::array, build::render_modes> indices{no_index, no_index}; std::atomic indices_until{}; - std::atomic found_logged{}; + std::atomic found_logged{}, hiding{}, hide_logged{}; std::mutex lock; // guards the picture's parts std::vector 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(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(); diff --git a/Extension/Skater/xray_render.h b/Extension/Skater/xray_render.h index 5b0390a9..eaba36b2 100644 --- a/Extension/Skater/xray_render.h +++ b/Extension/Skater/xray_render.h @@ -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; } diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 36b9c7e9..69779f5d 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -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 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`). diff --git a/Extension/UI/Overlay/overlay_console.cpp b/Extension/UI/Overlay/overlay_console.cpp index 52a2e7a0..8248ef14 100644 --- a/Extension/UI/Overlay/overlay_console.cpp +++ b/Extension/UI/Overlay/overlay_console.cpp @@ -41,6 +41,33 @@ std::vector console_completion_candidates(const std::vector 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(x)) == std::tolower(static_cast(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; } diff --git a/Extension/World/world_entities.cpp b/Extension/World/world_entities.cpp index 4b62c4e8..33efd3a7 100644 --- a/Extension/World/world_entities.cpp +++ b/Extension/World/world_entities.cpp @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include @@ -40,12 +42,17 @@ struct Wanted { Matrix matrix{}; bool hidden{}; std::optional 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 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 joint_source{}; +Physics& physics() { static auto* value = new Physics; return *value; } +std::mutex test_mutex; +std::string test_request; // under test_mutex +std::atomic 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 find_bodies(std::uintptr_t base, std::uintptr_t entity, std::uintptr_t world) { + const auto& p = physics(); + std::vector found; + std::vector> queue{{entity, 0}}; + std::set 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 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(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(name[i]), b = static_cast(prefix[i]); + if ((a >= 'A' && a <= 'Z' ? a + 32 : a) != (b >= 'A' && b <= 'Z' ? b + 32 : b)) return false; + } + return true; +} +std::optional asset_name(std::uintptr_t asset, std::uint64_t key) { + const auto at = ptr(asset + 0x18); + if (!at) return std::nullopt; + std::array 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 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 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& found, + std::set& 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(registry + 0x100); + if (!buckets || !count || count > (1u << 22)) return; + auto* section = reinterpret_cast(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(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 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) { +std::vector 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 found; + std::set 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::min(most, l.names.size()))}; +} + +std::optional 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, + 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(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::count_if(s.wanted.begin(), s.wanted.end(), [&](const auto& w) { return w.first.first == owner; })); } +std::vector live_entities() { + auto& s = state(); + std::lock_guard lock(s.mutex); + std::vector 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{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::array>, 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 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(); diff --git a/Extension/World/world_entities.h b/Extension/World/world_entities.h index f3962beb..0edc6348 100644 --- a/Extension/World/world_entities.h +++ b/Extension/World/world_entities.h @@ -4,6 +4,7 @@ #include #include #include +#include // 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; +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 (*floor)(const std::array& from, float depth, const std::vector& 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 (*hit)(const std::array& from, const std::array& to) noexcept{}; + std::vector (*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 (*)(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 = std::nullopt); + std::optional 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 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, 2>; +std::optional 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 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; diff --git a/Runtime/client_tick.cpp b/Runtime/client_tick.cpp index e9c1bdb7..ebdf0988 100644 --- a/Runtime/client_tick.cpp +++ b/Runtime/client_tick.cpp @@ -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)); diff --git a/Server/server_plugins.cpp b/Server/server_plugins.cpp index 790bfa60..28f3c748 100644 --- a/Server/server_plugins.cpp +++ b/Server/server_plugins.cpp @@ -232,7 +232,9 @@ std::optional 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 |off |on "); diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 84a8828e..76efb470 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -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