mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 15:07:06 +02:00
Limit how many objects each player can place (protocol 43) - Servers and lobby hosts set how many objects each player may have placed: object_limit in ReSkateServer.json and the "objects" command, "Objects per player" in the host options. Default 100; 0 is no limit - The host and a server's admins are not limited - At the limit a player's game refuses another object, from the native object tool and the park editor, and the menu shows how many they have placed - Enforced by the host or server, which shows everyone no more than the limit of each player's layout; objects already shown keep their place - The limit travels in the roster, which takes the protocol to version 43: builds before this cannot join sessions with builds after it - The protocol tests link again (the server browser needs the identity lists since the official servers change)
696 lines
35 KiB
C++
696 lines
35 KiB
C++
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Game/Build/addresses.h"
|
|
#include "Engine/Game/Build/20260929/engine.h"
|
|
#include "Engine/Game/Build/20260929/local_placements.h"
|
|
#include "Extension/Customization/local_customization_runtime.h"
|
|
#include "local_placements_runtime.h"
|
|
#include "Extension/Objects/ParkEditor/park_editor_runtime.h"
|
|
#include "network_object_runtime.h"
|
|
#include "Extension/Profile/runtime_internal.h"
|
|
#include "Extension/World/local_world_layers.h"
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <unordered_map>
|
|
|
|
namespace dingosdk::profile_runtime {
|
|
// Called on the native server tick for observation and client tick for replay.
|
|
|
|
thread_local PlacementBatch* placement_batch{};
|
|
|
|
PlacementsRuntime& placements_runtime() { static auto* r = new PlacementsRuntime; return *r; }
|
|
|
|
namespace {
|
|
// Advanced whenever a placement message, create, apply or delete may be changing
|
|
// the native table. One tick's updates ask for the same table several times: a
|
|
// read from the last few milliseconds is reused unless this moved since.
|
|
std::atomic<std::uint64_t> table_epoch{1};
|
|
void placement_table_changed() noexcept { table_epoch.fetch_add(1, std::memory_order_acq_rel); }
|
|
struct TableCache {
|
|
std::uintptr_t manager{};
|
|
std::uint64_t epoch{};
|
|
std::chrono::steady_clock::time_point at{};
|
|
std::vector<PlacementId> rows;
|
|
};
|
|
// A table node: {u32 id, entity at +8, next at +0x18}.
|
|
struct TableNode {
|
|
std::uint32_t id{}, unused{};
|
|
std::uint64_t entity{}, unused2{};
|
|
std::uintptr_t next{};
|
|
};
|
|
static_assert(sizeof(TableNode) == 0x20);
|
|
}
|
|
|
|
std::optional<std::vector<PlacementId>> placement_manager_ids(std::uintptr_t manager) {
|
|
// Callers hold native_mutex.
|
|
static TableCache cache;
|
|
const auto epoch = table_epoch.load(std::memory_order_acquire);
|
|
const auto now = std::chrono::steady_clock::now();
|
|
if (cache.manager && cache.manager == manager && cache.epoch == epoch && now - cache.at < std::chrono::milliseconds(5))
|
|
return cache.rows;
|
|
// Thousands of nodes in a big park: guarded same-process copies, one per node,
|
|
// rather than three system calls per node and a JSON object each.
|
|
std::uintptr_t vtable{}, buckets{};
|
|
std::uint32_t size{}, count{};
|
|
if (!memory::peek(manager, vtable) || vtable != local_runtime().base + addr::local_placements::placement_manager_vtable ||
|
|
!memory::peek(manager + 0x10, buckets) || !memory::peek(manager + 0x18, size) ||
|
|
!size || size > 65536 || !memory::peek(manager + 0x1c, count) || count > 65536)
|
|
return {};
|
|
std::vector<PlacementId> rows;
|
|
rows.reserve(count);
|
|
thread_local std::vector<std::uintptr_t> nodes;
|
|
nodes.clear();
|
|
for (unsigned bucket = 0; bucket < size && rows.size() < count; ++bucket) {
|
|
std::uintptr_t node{};
|
|
if (!memory::peek(buckets + bucket * 8, node)) return {};
|
|
while (node) {
|
|
// At most `count` nodes: a cycle or a list mutated mid-read runs past it.
|
|
if (rows.size() >= count) return {};
|
|
TableNode value;
|
|
if (!memory::peek(node, value)) return {};
|
|
nodes.push_back(node);
|
|
rows.push_back({value.id, value.entity});
|
|
node = value.next;
|
|
}
|
|
}
|
|
// A node reached twice means the table changed during the read.
|
|
std::sort(nodes.begin(), nodes.end());
|
|
if (rows.size() != count || std::adjacent_find(nodes.begin(), nodes.end()) != nodes.end()) return {};
|
|
cache = {manager, epoch, now, rows};
|
|
return rows;
|
|
}
|
|
|
|
void queue_placement_save() {
|
|
auto& r = placements_runtime();
|
|
if (r.map.empty() || r.save_failed) return;
|
|
if (r.personal_document) {
|
|
// Session edits/clear operations must never overwrite the guest's save.
|
|
r.clear_saved = true;
|
|
return;
|
|
}
|
|
std::lock_guard lock(r.save_mutex);
|
|
r.pending[r.map] = r.document.maps.at(r.map);
|
|
r.save_wake.notify_one();
|
|
}
|
|
|
|
void placement_reset_session() {
|
|
placement_table_changed();
|
|
reset_network_object_world();
|
|
reset_park_editor();
|
|
auto& r = placements_runtime();
|
|
r.creation.reset();
|
|
r.creates.clear();
|
|
r.tracked.clear(); r.restore.clear(); r.late_restores.clear(); r.inflight.reset(); r.rows.clear(); r.teleport_token = 0; r.status.clear();
|
|
r.map.clear(); r.map_context = 0; r.next_poll = 0; r.map_since = 0; r.map_generation = 0;
|
|
r.restore_failed = false;
|
|
r.clearing = false; r.clear_entities.clear();
|
|
}
|
|
|
|
bool placement_session_ready() {
|
|
auto& r = placements_runtime();
|
|
auto& world = world_layers_runtime();
|
|
if (!r.store || r.failed || r.save_failed || !r.manager || !world.model.ready ||
|
|
world.model.map == WorldMap::none || !world.context) return false;
|
|
const std::string map(world_map_key(world.model.map));
|
|
if (r.map != map || r.map_context != world.context || r.map_generation != world.root_generation) {
|
|
placement_reset_session();
|
|
r.map = map; r.map_context = world.context; r.map_generation = world.root_generation; r.map_since = GetTickCount64();
|
|
queue_placement_layout(r);
|
|
if (r.failed) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool placement_same_pose(const profile::PlacedObject& a, const profile::PlacedObject& b) {
|
|
if (a.item != b.item) return false;
|
|
float distance{}, dot{};
|
|
for (unsigned i = 0; i < 3; ++i) distance += (a.position[i] - b.position[i]) * (a.position[i] - b.position[i]);
|
|
for (unsigned i = 0; i < 4; ++i) dot += a.rotation[i] * b.rotation[i];
|
|
return distance < 0.0001f && std::abs(dot) > 0.99999f && std::abs(a.scale - b.scale) < .0001f;
|
|
}
|
|
|
|
std::optional<profile::PlacedObject> placement_from_applied(const PlacementApplied& applied) {
|
|
// Retail mode 4 is a disposable preview; mode 1 is the confirmed object.
|
|
// Nonzero variant IDs have not been observed, so leave those native.
|
|
if (applied.words[16] != 1 || applied.words[1] != 0) return {};
|
|
profile::PlacedObject object; object.id = 1;
|
|
for (const auto& [key, item] : cosmetic_runtime().items) if (item.build_kit && item.hash == applied.words[0]) {
|
|
if (!object.item.empty()) return {}; // An ambiguous hash cannot be a durable identity.
|
|
object.item = key;
|
|
}
|
|
std::memcpy(object.position.data(), applied.words.data() + 8, sizeof(object.position));
|
|
std::memcpy(object.rotation.data(), applied.words.data() + 12, sizeof(object.rotation));
|
|
return profile::valid_placed_object(object) ? std::optional{object} : std::nullopt;
|
|
}
|
|
|
|
std::optional<std::set<std::uint64_t>> placement_live_entities(std::uintptr_t manager) {
|
|
// The diagnostic enumerator validates vtable, counts, linked-list cycles,
|
|
// and every node read. Never treat an unreadable table as an empty world.
|
|
const auto table = placement_manager_ids(manager);
|
|
if (!table) return {};
|
|
std::set<std::uint64_t> result;
|
|
for (const auto& row : *table) result.insert(row.entity);
|
|
return result;
|
|
}
|
|
|
|
void observe_placement(std::uint64_t entity, const profile::PlacedObject& object) {
|
|
auto& r = placements_runtime();
|
|
const auto known = r.tracked.find(entity);
|
|
const auto saved_id = known == r.tracked.end() ? 0 : known->second;
|
|
auto row = std::find_if(r.rows.begin(), r.rows.end(), [&](const auto& x) {
|
|
return x.entity == entity || (saved_id && x.saved_id == saved_id);
|
|
});
|
|
if (row == r.rows.end()) {
|
|
if (r.rows.size() >= 2048) return;
|
|
r.rows.push_back({r.next_token++, entity, saved_id, object, true});
|
|
} else {
|
|
row->entity = entity; row->saved_id = saved_id; row->object = object;
|
|
row->spawned = true; row->missing_polls = 0;
|
|
}
|
|
}
|
|
|
|
void reconcile_placement_rows(const std::set<std::uint64_t>& live) {
|
|
auto& r = placements_runtime();
|
|
const auto& objects = r.document.maps[r.map];
|
|
// Saved objects by id (the first of a duplicate, as a search would find): a
|
|
// search per row was rows x objects, up to two million steps a poll.
|
|
std::unordered_map<std::uint64_t, const profile::PlacedObject*> saved_by_id;
|
|
saved_by_id.reserve(objects.size());
|
|
for (const auto& object : objects) saved_by_id.emplace(object.id, &object);
|
|
for (auto& row : r.rows) {
|
|
if (row.entity && live.contains(row.entity)) {
|
|
row.spawned = true;
|
|
row.missing_polls = 0;
|
|
} else if (row.spawned && row.entity && row.missing_polls < 2) {
|
|
// The native hash table is mutated while placement messages are
|
|
// dispatched. Two isolated valid-but-incomplete snapshots must not
|
|
// make a visible object unselectable.
|
|
++row.missing_polls;
|
|
} else {
|
|
if (row.entity) r.tracked.erase(row.entity);
|
|
row.entity = 0;
|
|
row.spawned = false;
|
|
row.missing_polls = 0;
|
|
}
|
|
if (row.saved_id) {
|
|
const auto saved = saved_by_id.find(row.saved_id);
|
|
if (saved == saved_by_id.end()) row.saved_id = 0;
|
|
else if (!row.spawned) row.object = *saved->second;
|
|
}
|
|
}
|
|
std::erase_if(r.rows, [](const auto& row) { return !row.saved_id && !row.spawned; });
|
|
}
|
|
|
|
void placement_finish_batch(std::uintptr_t manager, const PlacementBatch& incoming) {
|
|
auto& r = placements_runtime();
|
|
if (manager != r.manager || !placement_session_ready()) return;
|
|
const auto live = placement_live_entities(manager);
|
|
PlacementBatch batch{incoming.type, {}};
|
|
for (const auto &applied : incoming.applied) {
|
|
const auto object = placement_from_applied(applied);
|
|
// The post-apply callback is direct evidence that this entity accepted
|
|
// the recipe. A concurrently mutating ID table may be unreadable or omit
|
|
// it for one snapshot; never discard the only acknowledgement for that.
|
|
if (object && network_object_observe(applied.entity, *object, incoming.network_source)) continue;
|
|
// Even an invalid/late acknowledgement may never become a local save.
|
|
if (incoming.network_source) continue;
|
|
if (object && park_editor_observe(applied.entity, *object)) continue;
|
|
batch.applied.push_back(applied);
|
|
}
|
|
auto& objects = r.document.maps[r.map];
|
|
bool changed{};
|
|
for (const auto& applied : batch.applied) {
|
|
auto candidate = placement_from_applied(applied);
|
|
if (!candidate) continue;
|
|
const auto claim_restore = [&]() -> std::optional<std::uint64_t> {
|
|
if (r.inflight && placement_matches_applied(*r.inflight, *candidate)) {
|
|
const auto id = r.inflight->id;
|
|
r.inflight.reset();
|
|
return id;
|
|
}
|
|
for (auto it = r.late_restores.begin(); it != r.late_restores.end();) {
|
|
if (std::none_of(objects.begin(), objects.end(), [&](const auto& x) { return x.id == it->id; })) {
|
|
it = r.late_restores.erase(it);
|
|
continue;
|
|
}
|
|
if (placement_matches_applied(*it, *candidate)) {
|
|
const auto id = it->id;
|
|
r.late_restores.erase(it);
|
|
return id;
|
|
}
|
|
++it;
|
|
}
|
|
return {};
|
|
};
|
|
// A native tool's object that got past the request filter while the
|
|
// lobby forbids placement. Delete it; it never reaches the saved layout.
|
|
const auto reject = [&] {
|
|
if (!incoming.native || lobby_object_placement_allowed() || r.tracked.contains(applied.entity))
|
|
return false;
|
|
retire_local_network_object(applied.entity, *candidate);
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{
|
|
{"event", "local_placement_blocked_object"}, {"type", incoming.type}, {"item", candidate->item}}.dump().c_str());
|
|
return true;
|
|
};
|
|
if (!r.enabled || r.clearing) {
|
|
// A request sent just before disabling may still be acknowledged.
|
|
// Remember its live identity without changing the saved layout.
|
|
if (const auto restored = claim_restore()) r.tracked[applied.entity] = *restored;
|
|
else if (reject()) continue;
|
|
observe_placement(applied.entity, *candidate);
|
|
continue;
|
|
}
|
|
const auto known = r.tracked.find(applied.entity);
|
|
if (known != r.tracked.end()) {
|
|
const auto object = std::find_if(objects.begin(), objects.end(), [&](const auto& x) { return x.id == known->second; });
|
|
if (object != objects.end()) {
|
|
// Applied recipes omit scale. Preserve the last authoritative
|
|
// value until update_placement_poses reads the native matrix.
|
|
candidate->id = object->id;
|
|
candidate->scale = object->scale;
|
|
if (!placement_same_pose(*object, *candidate)) {
|
|
*object = *candidate; changed = true;
|
|
}
|
|
}
|
|
} else if (const auto restored = claim_restore()) {
|
|
r.tracked[applied.entity] = *restored;
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "local_placement_restored"}, {"map", r.map},
|
|
{"id", *restored}, {"item", candidate->item}}.dump().c_str());
|
|
} else if (reject()) {
|
|
continue;
|
|
} else if (objects.size() < 1024 && r.next_id != UINT64_MAX) {
|
|
candidate->id = r.next_id++;
|
|
r.tracked[applied.entity] = candidate->id;
|
|
objects.push_back(*candidate); changed = true;
|
|
}
|
|
observe_placement(applied.entity, *candidate);
|
|
}
|
|
// Only explicit object/delete-all messages remove saved records. Destruction,
|
|
// expiry, replication gaps and level unload must never clear the save.
|
|
if (live && r.enabled && !r.clearing && (batch.type == 0x9a1d7771 || batch.type == 0xe6f66136 || batch.type == 0x8898bdc0 || batch.type == 0xd124eaf0)) {
|
|
for (auto it = r.tracked.begin(); it != r.tracked.end();) {
|
|
if (!live->contains(it->first)) {
|
|
const auto id = it->second;
|
|
std::erase_if(objects, [&](const auto& x) { return x.id == id; });
|
|
it = r.tracked.erase(it); changed = true;
|
|
} else ++it;
|
|
}
|
|
}
|
|
if (live) reconcile_placement_rows(*live);
|
|
if (changed) { park_editor_external_change(); queue_placement_save(); }
|
|
}
|
|
|
|
// Native create (705a6a69) and copy (2f51fbd9) requests from the game's own
|
|
// object tools. ReSkate's restores and network spawns call the server create
|
|
// directly and never pass through this handler.
|
|
constexpr bool placement_adds_objects(std::uint32_t type) {
|
|
return type == 0x705a6a69 || type == 0x2f51fbd9;
|
|
}
|
|
|
|
void placement_message_hook(std::uintptr_t manager, const void* message) {
|
|
PlacementBatch batch;
|
|
batch.native = true;
|
|
{ PreserveError preserve; read(reinterpret_cast<std::uintptr_t>(message) + 0xc, batch.type); }
|
|
if (placement_adds_objects(batch.type) && lobby_object_placement_allowed() && lobby_object_limit_reached()) {
|
|
// As below: the request never reaches the server, so nothing is created.
|
|
PreserveError preserve;
|
|
placements_runtime().status = object_limit_notice;
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{
|
|
{"event", "local_placement_over_limit"}, {"type", batch.type}, {"limit", lobby_object_limit()}}.dump().c_str());
|
|
return;
|
|
}
|
|
if (placement_adds_objects(batch.type) && !lobby_object_placement_allowed()) {
|
|
// Drop the request before the server sees it: no entity is created.
|
|
PreserveError preserve;
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{
|
|
{"event", "local_placement_blocked_request"}, {"type", batch.type}}.dump().c_str());
|
|
return;
|
|
}
|
|
struct Scope {
|
|
PlacementBatch* previous{placement_batch};
|
|
~Scope() { placement_batch = previous; }
|
|
} scope;
|
|
placement_batch = &batch;
|
|
placement_table_changed();
|
|
placements_runtime().message(manager, message);
|
|
placement_table_changed();
|
|
PreserveError preserve;
|
|
try {
|
|
std::lock_guard lock(local_runtime().native_mutex);
|
|
if (local_runtime().active.load(std::memory_order_acquire)) placement_finish_batch(manager, batch);
|
|
} catch (...) { dingosdk::logging::event(dingosdk::logging::Channel::objects, "{\"event\":\"local_placement_capture_failed\"}"); }
|
|
}
|
|
|
|
void placement_apply_hook(const void* entity, const void* recipe) {
|
|
if (placement_batch && placement_batch->network_source) {
|
|
PreserveError preserve;
|
|
std::lock_guard lock(local_runtime().native_mutex);
|
|
if (!network_object_creation_owner(placement_batch->network_source)) return;
|
|
// Preserve the native simulation owner. Steam ownership is tracked by
|
|
// the private source token and entity key, including late callbacks.
|
|
}
|
|
placements_runtime().apply(entity, recipe);
|
|
placement_table_changed();
|
|
PreserveError preserve;
|
|
try {
|
|
if (!placement_batch || placement_batch->applied.size() >= 1024) return;
|
|
PlacementApplied applied;
|
|
if (read(reinterpret_cast<std::uintptr_t>(entity), applied.entity) &&
|
|
read(reinterpret_cast<std::uintptr_t>(recipe), applied.words)) placement_batch->applied.push_back(applied);
|
|
} catch (...) {}
|
|
}
|
|
|
|
std::uintptr_t placement_construct_hook(std::uintptr_t manager, std::uintptr_t allocator) {
|
|
auto& r = placements_runtime();
|
|
const auto result = r.construct(manager, allocator);
|
|
PreserveError preserve;
|
|
std::lock_guard lock(local_runtime().native_mutex);
|
|
placement_reset_session(); r.manager = result;
|
|
return result;
|
|
}
|
|
|
|
std::uintptr_t placement_destroy_hook(std::uintptr_t manager, unsigned flags) {
|
|
auto& r = placements_runtime();
|
|
std::lock_guard lock(local_runtime().native_mutex);
|
|
{ PreserveError preserve; if (r.manager == manager) { placement_reset_session(); r.manager = 0; } }
|
|
return r.destroy(manager, flags);
|
|
}
|
|
|
|
bool queue_placement_create(const profile::PlacedObject& object, std::uint32_t item, std::uint32_t token) {
|
|
auto& r = placements_runtime();
|
|
return r.creates_enabled && r.server_create && r.creates.push(object, item, token, r.creation.source());
|
|
}
|
|
|
|
void update_placement_creates() {
|
|
// Called only by the native server park tick, while holding native_mutex.
|
|
// Keep the queue private: the client message serializer cannot carry IDs.
|
|
auto& r = placements_runtime();
|
|
if (!r.creates_enabled || r.creates.empty() || !r.server_create) return;
|
|
const auto base = local_runtime().base;
|
|
std::uintptr_t server{}, vtable{}, context{}, manager{}, begin{}, end{}, player{}, component{};
|
|
std::uint32_t manager_offset{}, component_offset{}, parent_offset{};
|
|
if (!read(base + addr::engine::game_server, server) || !server ||
|
|
!read(server, vtable) || vtable != base + addr::engine::server_vtable ||
|
|
!read(server + 8, context) || context != r.map_context ||
|
|
!read(base + addr::engine::context_player_manager_offset, manager_offset) || manager_offset > 0x1000000 ||
|
|
!read(context + manager_offset, manager) || !manager ||
|
|
!read(manager, vtable) || vtable != base + addr::engine::server_player_manager_vtable ||
|
|
!read(manager + 0xc8, begin) || !read(manager + 0xd0, end) ||
|
|
!begin || end < begin || (end - begin) % 8 || end - begin > 64 * 8) return;
|
|
// ReSkate peers are client replicas. The hosted native server has one human
|
|
// player; never accidentally dispatch for a bot or an ambiguous connection.
|
|
for (auto entry = begin; entry < end; entry += 8) {
|
|
std::uintptr_t candidate{};
|
|
std::uint8_t bot{}, spectator{}, ready{};
|
|
if (!read(entry, candidate) || !candidate || !read(candidate, vtable) ||
|
|
vtable != base + addr::engine::server_player_vtable || !read(candidate + 0x44, bot) ||
|
|
!read(candidate + 0x70, spectator)) return;
|
|
if (bot || spectator) continue;
|
|
if (player || !read(candidate + 0x2e3, ready) || !ready) return;
|
|
player = candidate;
|
|
}
|
|
if (!player || !read(base + addr::local_placements::player_component_offset, component_offset) ||
|
|
!read(base + addr::engine::player_parent_offset, parent_offset)) return;
|
|
const auto delta = static_cast<std::int64_t>(component_offset) - parent_offset;
|
|
if (delta < -0x10000 || delta > 0x10000 || player < 0x20000 ||
|
|
player > UINTPTR_MAX - 0x10000) return;
|
|
component = delta < 0 ? player - static_cast<std::uintptr_t>(-delta) : player + delta;
|
|
std::uint64_t native_owner{};
|
|
// The native render/collision activators run only for the native
|
|
// simulation owner. ReSkate peers have no native ServerPlayer, so every
|
|
// local replica must use this instance's player ID (legitimately zero).
|
|
// Steam ownership remains in the network key; it still controls saves,
|
|
// authoritative updates and removal when a peer leaves.
|
|
if (!read(component + 0x98, native_owner)) return;
|
|
const auto request = r.creates.take(r.creation.source());
|
|
if (!request) return;
|
|
const bool remote = is_network_object_source(request->token);
|
|
if (remote) {
|
|
if (!network_object_creation_owner(request->token)) return;
|
|
} else if (!r.creation.accepts(request->token)) return;
|
|
|
|
PlacementCreateRecipe recipe(*request, native_owner);
|
|
const void* data = recipe.words.data();
|
|
PlacementBatch batch{0x705a6a69, {}, remote ? request->token : 0};
|
|
struct Scope {
|
|
PlacementBatch* previous{placement_batch};
|
|
~Scope() { placement_batch = previous; }
|
|
} scope;
|
|
placement_batch = &batch;
|
|
placement_table_changed();
|
|
r.server_create(r.manager, &data, 0, player, 0);
|
|
placement_table_changed();
|
|
placement_finish_batch(r.manager, batch);
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects,
|
|
dingosdk::Json{{"event", "local_placement_server_create"}, {"remote", remote},
|
|
{"applied", batch.applied.size()}}.dump().c_str());
|
|
}
|
|
|
|
void update_placement_poses() {
|
|
auto& r = placements_runtime();
|
|
if (!placement_session_ready()) return;
|
|
update_placement_creates();
|
|
update_park_editor_moves();
|
|
update_network_object_moves();
|
|
if (!placement_session_ready() || r.clearing || park_editor_owns_placements() || GetTickCount64() < r.next_poll) return;
|
|
r.next_poll = GetTickCount64() + 1000;
|
|
const auto live = placement_live_entities(r.manager);
|
|
if (!live) return;
|
|
reconcile_placement_rows(*live);
|
|
auto& objects = r.document.maps[r.map]; bool changed{};
|
|
for (auto& row : r.rows) {
|
|
if (!row.spawned) continue;
|
|
alignas(16) std::array<std::uint64_t, 2> reference{};
|
|
alignas(16) std::array<std::uint64_t, 3> query{};
|
|
alignas(16) std::array<float, 12> transform{};
|
|
if (r.resolve(reference.data(), row.entity, true) != reference.data() || !r.valid(reference.data()) ||
|
|
r.query(query.data(), reference[0]) != query.data() || r.pose(transform.data(), query.data()) != transform.data()) continue;
|
|
auto next = row.object;
|
|
std::copy_n(transform.data() + 8, 3, next.position.data());
|
|
std::copy_n(transform.data() + 4, 4, next.rotation.data());
|
|
next.scale = transform[0];
|
|
if (!profile::valid_placed_object(next)) continue;
|
|
row.object = next;
|
|
if (!r.enabled || !row.saved_id) continue;
|
|
const auto object = std::find_if(objects.begin(), objects.end(), [&](const auto& x) { return x.id == row.saved_id; });
|
|
if (object != objects.end() && !placement_same_pose(next, *object)) {
|
|
next.id = object->id; *object = next; changed = true;
|
|
}
|
|
}
|
|
if (changed) { park_editor_external_change(); queue_placement_save(); }
|
|
}
|
|
|
|
std::uintptr_t placement_client_channel() {
|
|
auto& r = placements_runtime();
|
|
std::array<std::uintptr_t, 2> context{};
|
|
r.context(context.data());
|
|
std::uint32_t offset{}, connection_offset{};
|
|
std::uint8_t ready{};
|
|
std::uintptr_t connection{}, channel{};
|
|
const auto base = local_runtime().base;
|
|
if (!context[0] || !read(base + addr::local_placements::client_ready_offset, offset) || offset > 0x1000000 ||
|
|
!read(context[0] + offset + 0x100, ready) || ready != 1 ||
|
|
!read(base + addr::engine::context_client_connection_offset, connection_offset) || connection_offset > 0x1000000 ||
|
|
!read(context[0] + connection_offset, connection) || !connection ||
|
|
!read(connection + 0x50, channel) || !channel) return 0;
|
|
return channel;
|
|
}
|
|
|
|
void send_placement_delete(std::uintptr_t channel, const std::vector<std::uint32_t>& ids) {
|
|
auto& r = placements_runtime();
|
|
// Native delete-list message, containing only validated IDs of tracked
|
|
// objects. The original API also borrows a stack message while sending.
|
|
std::vector<std::uint32_t> payload(ids.size() + 2);
|
|
payload[0] = payload[1] = static_cast<std::uint32_t>(ids.size());
|
|
std::copy(ids.begin(), ids.end(), payload.begin() + 2);
|
|
alignas(16) std::array<std::byte, 0x50> message{};
|
|
r.delete_ctor(message.data());
|
|
struct Destroy { decltype(r.message_destroy) call; void* message; ~Destroy() { call(message); } } destroy{r.message_destroy, message.data()};
|
|
const void* data = payload.data() + 2;
|
|
std::memcpy(message.data() + 0x48, &data, sizeof(data));
|
|
placement_table_changed();
|
|
r.send(channel, message.data());
|
|
}
|
|
|
|
void update_placement_clear() {
|
|
auto& r = placements_runtime();
|
|
if (!r.clear_saved || r.clear_map != r.map) return;
|
|
const auto table = placement_manager_ids(r.manager);
|
|
if (!table) return;
|
|
std::vector<std::uint32_t> ids;
|
|
for (const auto& row : *table)
|
|
if (r.clear_entities.contains(row.entity)) ids.push_back(row.id);
|
|
if (ids.empty() || (r.clear_sent && GetTickCount64() > r.sent_at + 10000)) {
|
|
for (auto entity : r.clear_entities) r.tracked.erase(entity);
|
|
std::set<std::uint64_t> live;
|
|
for (const auto& row : *table) live.insert(row.entity);
|
|
std::erase_if(r.rows, [&](const auto& row) {
|
|
return r.clear_entities.contains(row.entity) && !live.contains(row.entity);
|
|
});
|
|
reconcile_placement_rows(live);
|
|
r.clearing = false;
|
|
r.status = ids.empty() ? "Selected objects deleted." : "Save updated. Reload the level to remove remaining objects.";
|
|
return;
|
|
}
|
|
if (r.clear_sent) return;
|
|
const auto channel = placement_client_channel();
|
|
if (!channel) return;
|
|
r.clear_sent = true; r.sent_at = GetTickCount64();
|
|
send_placement_delete(channel, ids);
|
|
r.status = "Save updated. Removing selected objects...";
|
|
}
|
|
|
|
bool begin_placement_delete(const std::set<std::uint64_t>& tokens) {
|
|
auto& r = placements_runtime();
|
|
if (r.clearing || r.inflight || park_editor_owns_placements() || tokens.empty()) return false;
|
|
std::set<std::uint64_t> saved_ids;
|
|
r.clear_map = r.map; r.clear_entities.clear();
|
|
for (const auto& row : r.rows) if (tokens.contains(row.token)) {
|
|
if (row.saved_id) saved_ids.insert(row.saved_id);
|
|
if (row.entity) r.clear_entities.insert(row.entity);
|
|
if (row.token == r.teleport_token) r.teleport_token = 0;
|
|
}
|
|
auto& layout = r.document.maps[r.map];
|
|
std::erase_if(layout, [&](const auto& x) { return saved_ids.contains(x.id); });
|
|
std::erase_if(r.restore, [&](auto id) { return saved_ids.contains(id); });
|
|
std::erase_if(r.late_restores, [&](const auto& object) { return saved_ids.contains(object.id); });
|
|
r.clearing = true; r.clear_sent = false; r.clear_saved = false;
|
|
{ std::lock_guard pending(r.save_mutex); r.clear_wait_map = r.map; r.clear_wait_layout = layout; }
|
|
r.status = "Deleting selected objects...";
|
|
park_editor_external_change();
|
|
queue_placement_save();
|
|
return true;
|
|
}
|
|
|
|
std::array<float, 16> placement_teleport_transform(const profile::PlacedObject& object) {
|
|
// Keep the skater upright, beside and above the object's origin. The native
|
|
// teleport manager handles streaming, ground checks and physics reset.
|
|
return {1,0,0,0, 0,1,0,0, 0,0,1,0,
|
|
object.position[0] + 3, object.position[1] + 2, object.position[2], 1};
|
|
}
|
|
|
|
std::uint32_t send_placement_teleport(std::uintptr_t manager, const profile::PlacedObject& object) {
|
|
auto& r = placements_runtime();
|
|
alignas(16) const auto transform = placement_teleport_transform(object);
|
|
alignas(16) std::array<std::uint64_t, 2> transition{};
|
|
r.transition_ctor(transition.data());
|
|
struct Destroy { decltype(r.transition_destroy) call; void* value; ~Destroy() { call(value); } } destroy{r.transition_destroy, transition.data()};
|
|
return r.teleport(manager, transform.data(), 1, transition.data());
|
|
}
|
|
|
|
void update_position_teleport() {
|
|
auto& r = placements_runtime();
|
|
if (!r.position_teleport) return;
|
|
if (GetTickCount64() > r.position_teleport_at + 5000) {
|
|
r.position_teleport.reset();
|
|
dingosdk::logging::write(dingosdk::logging::Level::warning, dingosdk::logging::Channel::objects,
|
|
"Teleport dropped: the skater was busy for 5 s (a bail, a menu or an activity).");
|
|
return;
|
|
}
|
|
std::uintptr_t manager{}; std::uint32_t state{};
|
|
if (!r.teleport || !r.transition_ctor || !r.transition_destroy || !placement_client_channel() ||
|
|
!read(local_runtime().base + addr::local_placements::teleport_manager, manager) || !manager ||
|
|
!read(manager, state) || state != 0) return;
|
|
const auto at = *r.position_teleport;
|
|
r.position_teleport.reset();
|
|
// Upright at the spot. The native teleport manager handles streaming, ground checks and physics reset.
|
|
alignas(16) const std::array<float, 16> transform{1,0,0,0, 0,1,0,0, 0,0,1,0, at[0], at[1], at[2], 1};
|
|
alignas(16) std::array<std::uint64_t, 2> transition{};
|
|
r.transition_ctor(transition.data());
|
|
struct Destroy { decltype(r.transition_destroy) call; void* value; ~Destroy() { call(value); } } destroy{r.transition_destroy, transition.data()};
|
|
const auto request = r.teleport(manager, transform.data(), 1, transition.data());
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "position_teleport_requested"},
|
|
{"x", at[0]}, {"y", at[1]}, {"z", at[2]}, {"request", request}}.dump().c_str());
|
|
}
|
|
|
|
void update_placement_teleport() {
|
|
auto& r = placements_runtime();
|
|
if (!r.teleport_token) return;
|
|
const auto row = std::find_if(r.rows.begin(), r.rows.end(), [&](const auto& x) { return x.token == r.teleport_token; });
|
|
if (row == r.rows.end() || !profile::valid_placed_object(row->object)) { r.teleport_token = 0; return; }
|
|
if (GetTickCount64() > r.teleport_queued_at + 5000) {
|
|
r.teleport_token = 0; r.status = "Teleport unavailable. Finish the current activity and try again."; return;
|
|
}
|
|
std::uintptr_t manager{}; std::uint32_t state{};
|
|
if (!placement_client_channel() || !read(local_runtime().base + addr::local_placements::teleport_manager, manager) || !manager ||
|
|
!read(manager, state) || state != 0) return;
|
|
const auto object = row->object;
|
|
const auto token = r.teleport_token; r.teleport_token = 0;
|
|
const auto request = send_placement_teleport(manager, object);
|
|
r.status = "Teleport requested. Close the menu to resume skating.";
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "local_placement_teleport_requested"}, {"map", r.map}, {"row", token}, {"request", request}}.dump().c_str());
|
|
}
|
|
|
|
void update_placement_restore() {
|
|
auto& r = placements_runtime();
|
|
if (!placement_session_ready()) return;
|
|
update_park_editor();
|
|
// Teleport is independent of the placement mutation channel. Service it
|
|
// before background editor/network work can return early.
|
|
update_placement_teleport();
|
|
if (park_editor_owns_placements()) return;
|
|
if (r.clearing) { update_placement_clear(); return; }
|
|
update_network_objects();
|
|
if (network_objects_inflight()) return;
|
|
if (!r.enabled || r.restore_failed || GetTickCount64() < r.map_since + 3000) return;
|
|
if (r.inflight) {
|
|
if (GetTickCount64() > r.sent_at + 10000) {
|
|
if (r.late_restores.size() < 1024) r.late_restores.push_back(*r.inflight);
|
|
r.inflight.reset();
|
|
r.restore_failed = false;
|
|
r.status = "One saved object did not acknowledge its restore. It was skipped without blocking editing.";
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects,
|
|
"{\"event\":\"local_placement_restore_unacknowledged\",\"retry\":false,\"editing_released\":true}");
|
|
}
|
|
return;
|
|
}
|
|
if (r.restore.empty() || cosmetic_runtime().items.empty() || !placement_client_channel()) return;
|
|
const auto id = r.restore.front();
|
|
const auto& objects = r.document.maps[r.map];
|
|
const auto object = std::find_if(objects.begin(), objects.end(), [&](const auto& x) { return x.id == id; });
|
|
if (object == objects.end()) { r.restore.erase(r.restore.begin()); return; }
|
|
const auto item = cosmetic_runtime().items.find(object->item);
|
|
if (item == cosmetic_runtime().items.end() || !item->second.build_kit) {
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "local_placement_restore_missing_item"}, {"item", object->item}}.dump().c_str());
|
|
r.restore.erase(r.restore.begin()); return; // retain unavailable entries in the JSON
|
|
}
|
|
if (!r.creation.source()) { r.restore_failed = true; r.status = "Object request IDs exhausted; restart the game."; return; }
|
|
if (!queue_placement_create(*object, item->second.hash, r.creation.source())) return;
|
|
r.inflight = *object; r.sent_at = GetTickCount64(); r.restore.erase(r.restore.begin());
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "local_placement_restore_sent"}, {"map", r.map}, {"id", id}}.dump().c_str());
|
|
}
|
|
|
|
void initialize_placement_store(const std::filesystem::path& profile_path) {
|
|
auto& r = placements_runtime();
|
|
try {
|
|
r.store = std::make_unique<profile::PlacementStore>(profile_path.parent_path() / L"objects.sqlite3");
|
|
r.document = r.store->snapshot();
|
|
r.enabled = local_runtime().store->bool_option("ReSkate.ObjectPersistence").value_or(true);
|
|
r.writer = std::jthread([&r](std::stop_token stop) {
|
|
while (!stop.stop_requested()) {
|
|
std::map<std::string, profile::ObjectLayout, std::less<>> pending;
|
|
{
|
|
std::unique_lock lock(r.save_mutex);
|
|
r.save_wake.wait_for(lock, std::chrono::milliseconds(250), [&] { return !r.pending.empty() || stop.stop_requested(); });
|
|
pending.swap(r.pending);
|
|
}
|
|
if (r.save_failed) continue;
|
|
try {
|
|
for (const auto& [map, objects] : pending) {
|
|
r.store->replace_map(map, objects);
|
|
{ std::lock_guard lock(r.save_mutex);
|
|
if (r.clear_wait_map == map && objects == r.clear_wait_layout) r.clear_saved = true;
|
|
}
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, dingosdk::Json{{"event", "local_placements_saved"}, {"map", map}, {"objects", objects.size()}}.dump().c_str());
|
|
}
|
|
} catch (...) {
|
|
r.save_failed = true;
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, "{\"event\":\"local_placements_save_failed\",\"previous_save_retained\":true}");
|
|
}
|
|
}
|
|
});
|
|
dingosdk::logging::event(dingosdk::logging::Channel::objects, "{\"event\":\"local_placements_initialized\",\"file\":\"objects.sqlite3\"}");
|
|
} catch (...) { r.failed = true; dingosdk::logging::event(dingosdk::logging::Channel::objects, "{\"event\":\"local_placements_unavailable\"}"); }
|
|
}
|
|
}
|