mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-18 17:55:28 +02:00
On a dedicated server with object scaling off, a loaded park's scaled objects were resized anyway (and after a refused resize the pose poll saved 1x over their scale), and a scaled paste, set or undo ended in a resize the server would not take, so the edit waited on it. The placements now know whether this game may resize (set by the session from the server's setting and the role, the host and roles that may always build exempt, allowed outside a session): where it may not, objects stay 1x in the world, nothing waits on a resize, and the layout keeps their scale for when scaling is allowed again. A transaction test pastes a scaled object with scaling off.
735 lines
37 KiB
C++
735 lines
37 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.resizes.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);
|
|
// A multi-object edit (a paste, a set, an undo) reconciles after each of its creates, before
|
|
// its objects are saved: without these, every create unlinked the ones before it, which
|
|
// could then not be selected, moved or deleted.
|
|
if (const auto* pending = park_editor_pending_layout())
|
|
for (const auto& object : *pending) 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;
|
|
// claim_restore gave the candidate the saved scale; the spawned object is at 1x. Where
|
|
// the session has scaling off it stays 1x in the world, and its saved scale is kept.
|
|
if (candidate->scale != 1.0f && lobby_object_scaling_allowed() && r.resizes.size() < 1024) {
|
|
auto saved = *candidate;
|
|
saved.id = *restored;
|
|
r.resizes.push_back({applied.entity, saved});
|
|
}
|
|
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());
|
|
}
|
|
|
|
// After the native server tick, under native_mutex, like the editor's own moves.
|
|
void update_placement_resizes() {
|
|
auto& r = placements_runtime();
|
|
if (r.resizes.empty() || !network_object_native_ready()) return;
|
|
const auto live = placement_live_entities(r.manager);
|
|
if (!live) return;
|
|
const auto now = GetTickCount64();
|
|
std::erase_if(r.resizes, [&](auto& pending) {
|
|
if (!live->contains(pending.entity) || !r.tracked.contains(pending.entity)) return true; // gone
|
|
if (!pending.settled) {
|
|
pending.settled = true;
|
|
return false;
|
|
}
|
|
if (!pending.resized_at) {
|
|
move_network_object(pending.entity, pending.object);
|
|
pending.resized_at = now;
|
|
return false;
|
|
}
|
|
return now > pending.resized_at + 1500;
|
|
});
|
|
}
|
|
|
|
void update_placement_poses() {
|
|
auto& r = placements_runtime();
|
|
if (!placement_session_ready()) return;
|
|
update_placement_creates();
|
|
update_park_editor_moves();
|
|
update_network_object_moves();
|
|
update_placement_resizes();
|
|
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;
|
|
if (std::any_of(r.resizes.begin(), r.resizes.end(), [&](const auto& x) { return x.entity == row.entity; }))
|
|
continue; // Its native scale is still 1x, or the resize is not yet read back.
|
|
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());
|
|
// Where the session has scaling off, an object is 1x in the world whatever it was saved at:
|
|
// the saved scale stays, for when scaling is allowed again.
|
|
if (lobby_object_scaling_allowed()) 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\"}"); }
|
|
}
|
|
}
|