Files
ReSkate/Extension/Objects/ParkEditor/park_editor_runtime.cpp
T
2026-10-02 14:40:41 -05:00

640 lines
30 KiB
C++

#include "park_editor_runtime.h"
#include "park_editor_internal.h"
#include "Extension/Customization/local_customization.h"
#include "Extension/Customization/local_customization_runtime.h"
#include "Extension/Objects/object_categories.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Game/Build/addresses.h"
#include "Engine/Game/Build/20260929/park_editor.h"
#include "Extension/Objects/local_placements_runtime.h"
#include "Extension/Objects/network_object_runtime.h"
#include "park_document.h"
#include "park_mods.h"
namespace dingosdk::profile_runtime {
namespace park_editor_detail {
EditorRuntime &editor_state() {
static auto *value = new EditorRuntime;
return *value;
}
bool same_layout(const profile::ObjectLayout &a, const profile::ObjectLayout &b) {
if (a.size() != b.size())
return false;
for (std::size_t i = 0; i < a.size(); ++i)
if (a[i].id != b[i].id || !placement_same_pose(a[i], b[i]))
return false;
return true;
}
const profile::PlacedObject *find_object(const profile::ObjectLayout &objects, std::uint64_t id) {
const auto it = std::find_if(objects.begin(), objects.end(), [&](const auto &x) { return x.id == id; });
return it == objects.end() ? nullptr : &*it;
}
std::atomic<bool> park_mod_list_visible{true};
void refresh_park_mods() {
auto &e = editor_state();
e.next_park_scan = GetTickCount64() + 5000;
try {
e.park_mods = e.data_root.empty() ? std::vector<editor::ParkMod>{} : editor::list_park_mods(e.data_root);
e.legacy_parks = e.directory.empty() ? std::vector<std::string>{} : editor::list_parks(e.directory);
} catch (...) { /* An unreadable Mods folder just lists nothing. */ }
if (!e.project.empty() && std::none_of(e.park_mods.begin(), e.park_mods.end(),
[&](const auto &mod) { return mod.folder == e.project; }))
e.project.clear(); // The project was removed or renamed outside the game.
}
// Multiplayer guests play on the host's objects; only the host swaps layouts.
bool is_lobby_guest() {
return placements_runtime().personal_document.has_value();
}
bool idle() {
const auto &r = placements_runtime();
const auto &e = editor_state();
return lobby_object_placement_allowed() && e.native_ready && !e.failed && !e.transaction && !network_objects_inflight() && r.enabled &&
!r.clearing && !r.inflight && r.restore.empty() && !r.restore_failed;
}
void fail(std::string reason) {
auto &e = editor_state();
if (e.transaction && e.transaction->sent && !e.transaction->steps.empty() &&
e.transaction->steps.front().kind == Kind::create)
e.late_create = e.transaction->steps.front();
e.failed = true;
e.transaction.reset();
++e.revision;
e.status =
std::move(reason) + " Reload the level before editing again. The last committed layout is retained.";
logging::write(logging::Level::error, logging::Channel::objects, e.status);
}
bool begin(profile::ObjectLayout target, int history) {
auto &r = placements_runtime();
auto &e = editor_state();
const auto &before = r.document.maps[r.map];
if (same_layout(before, target)) {
e.status = "No changes.";
++e.revision;
return true;
}
// Validate the entire layout before sending even its first delete.
profile::PlacementSnapshot check;
check.maps.emplace(r.map, target);
(void)profile::encode_placements(check);
for (const auto &object : target) {
if (object.id == UINT64_MAX)
throw std::runtime_error("Park object identity is out of range.");
const auto item = cosmetic_runtime().items.find(object.item);
if (item == cosmetic_runtime().items.end() || !item->second.build_kit)
throw std::runtime_error("This park needs an object that is not installed: " + object.item);
}
Transaction tx;
tx.change = {before, std::move(target)};
tx.history = history;
tx.waiting_since = GetTickCount64();
for (const auto &old : before) {
const auto *next = find_object(tx.change.after, old.id);
if (!next || next->item != old.item) {
const auto row = std::find_if(r.rows.begin(), r.rows.end(),
[&](const auto &x) { return x.saved_id == old.id && x.spawned; });
// A saved object that is already absent needs no native delete; its
// record can still be removed or replaced by this transaction.
if (row != r.rows.end()) tx.steps.push_back({Kind::erase, old, row->entity});
}
}
for (const auto &next : tx.change.after) {
const auto *old = find_object(before, next.id);
if (!old || old->item != next.item)
tx.steps.push_back({Kind::create, next});
else if (!placement_same_pose(*old, next)) {
const auto row = std::find_if(r.rows.begin(), r.rows.end(),
[&](const auto &x) { return x.saved_id == old->id && x.spawned; });
if (row == r.rows.end())
throw std::runtime_error("The selected object is not currently spawned.");
tx.steps.push_back({Kind::move, next, row->entity});
}
}
e.transaction = std::move(tx);
e.status = "Applying park edit...";
++e.revision;
return true;
}
} // namespace park_editor_detail
using namespace park_editor_detail;
namespace {
void refresh_catalog() {
auto &e = editor_state();
auto &c = cosmetic_runtime();
if (GetTickCount64() < e.next_catalog)
return;
e.next_catalog = GetTickCount64() + 1000;
const auto snapshot_shared = local_runtime().store->shared_snapshot();
const auto& snapshot = *snapshot_shared;
if (e.assets && e.catalog_revision == snapshot.revision && e.catalog_manager == c.item_manager)
return;
const auto config = snapshot.extensions.value("object_dropper", Json::object());
const auto inventory = config.value("inventory", Json::object());
const auto catalog = config.value("catalog", Json::object());
const auto &cache = content_cache::catalogs();
const auto groups = objects::object_groups(cache);
auto assets = std::make_shared<std::vector<EditorAsset>>();
for (const auto &[key, item] : c.items) {
if (!item.build_kit || !inventory.value(key, false) || assets->size() >= 4096)
continue;
std::string title = key.starts_with("own_") ? key.substr(4) : key;
std::replace(title.begin(), title.end(), '_', ' ');
const auto metadata = profile::item_display_metadata(key, catalog.value(key, Json::object()));
// The game's category (Ramp, Rail, ...) when the content cache places it.
const auto placement = objects::object_placement(key, cache, groups);
assets->push_back({key, metadata.value("title", title),
placement.category ? placement.category->title : item.category});
}
std::sort(assets->begin(), assets->end(), [](const auto &a, const auto &b) {
return a.category == b.category ? a.title < b.title : a.category < b.category;
});
e.assets = std::move(assets);
e.catalog_revision = snapshot.revision;
e.catalog_manager = c.item_manager;
}
bool read_object_pose(std::uint64_t entity, std::array<std::uint64_t, 3> &query,
std::array<float, 12> &pose) {
auto &r = placements_runtime();
alignas(16) std::array<std::uint64_t, 2> reference{};
// Resolve identity without the native grab-eligibility checks. Those reject
// resting/static bodies (flag 0x800), which the editor must still position.
if (r.resolve(reference.data(), entity, false) != reference.data() || reference[0] != entity ||
!r.valid(reference.data()) || r.query(query.data(), entity) != query.data() ||
r.pose(pose.data(), query.data()) != pose.data())
return false;
// The native position W lane can contain 0xffc00000 (a NaN marker). Only
// scale XYZ, quaternion XYZW and position XYZ are numeric transform data.
// Preserve both padding lanes verbatim when passing the pose back to native.
for (const auto index : {0, 1, 2, 4, 5, 6, 7, 8, 9, 10})
if (!std::isfinite(pose[index]))
return false;
return true;
}
bool object_has_pose(std::uint64_t entity, const profile::PlacedObject &expected) {
alignas(16) std::array<std::uint64_t, 3> query{};
alignas(16) std::array<float, 12> pose{};
if (!read_object_pose(entity, query, pose)) return false;
auto actual = expected;
std::copy_n(pose.begin() + 4, 4, actual.rotation.begin());
std::copy_n(pose.begin() + 8, 3, actual.position.begin());
actual.scale = pose[0];
return placement_same_pose(expected, actual) &&
std::abs(pose[1] - expected.scale) < .0001f &&
std::abs(pose[2] - expected.scale) < .0001f;
}
bool move_created_duplicate(const profile::PlacedObject &object,
const std::set<std::uint64_t> &before,
const std::set<std::uint64_t> &live) {
return std::any_of(live.begin(), live.end(), [&](const auto entity) {
return !before.contains(entity) && object_has_pose(entity, object);
});
}
void move_existing(std::uint64_t entity, const profile::PlacedObject &object) {
auto &e = editor_state();
alignas(16) std::array<std::uint64_t, 3> query{};
alignas(16) std::array<float, 12> pose{};
if (!read_object_pose(entity, query, pose))
throw std::runtime_error("Selected object's native transform is unavailable.");
std::fill_n(pose.begin(), 3, object.scale);
std::copy(object.rotation.begin(), object.rotation.end(), pose.begin() + 4);
std::copy(object.position.begin(), object.position.end(), pose.begin() + 8);
alignas(16) std::array<std::uint64_t, 5> physics{};
if (e.physics_query(query.data(), physics.data()) != physics.data())
throw std::runtime_error("Selected object's physics component is unavailable.");
alignas(16) std::array<std::uint64_t, 2> body{};
if (physics[2] && physics[3] && !read(physics[3], body))
throw std::runtime_error("Selected object's physics body could not be read.");
std::uintptr_t source{};
if (!read(local_runtime().base + addr::park_editor::transform_notification_source, source))
throw std::runtime_error("Native transform notification source is unavailable.");
if (physics[2] && physics[3]) {
// Native body setter expects position XYZ + one uniform scale followed by quaternion.
alignas(16) std::array<float, 8> rigid{pose[8], pose[9], pose[10], object.scale,
pose[4], pose[5], pose[6], pose[7]};
e.physics_move(body.data(), rigid.data());
}
e.transform_move(query.data(), pose.data(), source);
}
} // namespace
void initialize_park_editor(const std::filesystem::path &directory) {
auto &e = editor_state();
e.directory = directory / L"parks";
e.surface_api = editor::surface_api(local_runtime().base);
e.picking_api = editor::picking_api(local_runtime().base);
e.highlight_api = editor::highlight_api(local_runtime().base);
const auto &movement_contracts = addr::park_editor::movement_contracts;
e.native_ready =
std::all_of(movement_contracts.begin(), movement_contracts.end(), [](const auto &contract) {
std::array<unsigned char, 32> bytes{};
return memory::read_bytes(local_runtime().base + contract.rva, bytes.data(), bytes.size()) &&
bytes == contract.bytes;
});
if (e.native_ready) {
e.physics_query =
reinterpret_cast<decltype(e.physics_query)>(local_runtime().base + movement_contracts[0].rva);
e.physics_move =
reinterpret_cast<decltype(e.physics_move)>(local_runtime().base + movement_contracts[1].rva);
e.transform_move =
reinterpret_cast<decltype(e.transform_move)>(local_runtime().base + movement_contracts[2].rva);
}
refresh_park_mods();
}
void set_park_mods_root(const std::filesystem::path &data_root) {
std::lock_guard lock(local_runtime().native_mutex);
editor_state().data_root = data_root;
refresh_park_mods();
}
void reset_park_editor() {
auto &e = editor_state();
++e.generation;
++e.revision;
e.transaction.reset();
e.failed = false;
e.undo.clear();
e.redo.clear();
e.status.clear();
e.probe.reset();
e.surface = {};
e.last_preview_token = 0;
e.selection.clear();
e.selection_time = 0;
e.highlights.clear(); // The old realm is gone; its entity IDs must never reach the new realm.
e.native_drops.clear();
e.client_roots.clear();
e.late_create.reset();
}
bool network_object_native_ready() {
return editor_state().native_ready;
}
void move_network_object(std::uint64_t entity, const profile::PlacedObject &object) {
move_existing(entity, object);
}
bool park_editor_owns_placements() {
// A failed editor has no transaction to own. Treating the error latch as
// ownership swallowed every later native placement acknowledgement and
// permanently starved the regular place/delete updater.
return editor_state().transaction.has_value();
}
void park_editor_external_change() {
auto &e = editor_state();
if (park_editor_owns_placements())
return;
++e.revision;
e.undo.clear();
e.redo.clear();
}
bool park_editor_observe(std::uint64_t entity, const profile::PlacedObject &object) {
auto &e = editor_state();
if (!e.transaction) {
if (!e.late_create) return false;
auto observed = object;
observed.scale = e.late_create->object.scale;
if (e.late_create->before_entities.contains(entity) ||
!placement_same_pose(e.late_create->object, observed)) return false;
// Consume only the delayed create that belonged to the failed editor;
// unrelated native placements must continue through normal persistence.
e.late_create.reset();
return true;
}
if (!e.transaction || e.transaction->steps.empty() || !e.transaction->sent)
return true;
auto &step = e.transaction->steps.front();
auto observed = object;
// The native create recipe acknowledgement exposes position/rotation but not
// the matrix scale. The following move/readback stage verifies the requested
// scale against the actual world transform before committing it.
observed.scale = step.object.scale;
if (step.kind == Kind::create && !step.before_entities.contains(entity) &&
placement_same_pose(step.object, observed)) {
step.entity = entity;
step.acknowledged = true;
}
return true;
}
void update_park_editor_moves() noexcept {
// After the native server park tick, under native_mutex. Client and render
// threads must not change authoritative world components.
auto &e = editor_state();
try {
expire_preview();
if (!e.transaction || !placement_session_ready() || !e.transaction || e.transaction->steps.empty())
return;
auto &tx = *e.transaction;
auto &step = tx.steps.front();
if (tx.preview && step.kind == Kind::move) {
auto &preview = *tx.preview;
const auto live = placement_live_entities(placements_runtime().manager);
if (!live) return; // A single concurrent table read is not an edit failure.
if (!preview.identities_captured) {
preview.identities = *live;
preview.identities_captured = true;
}
for (auto &move : tx.steps) {
if (!live->contains(move.entity)) {
// Wait for another stable server snapshot. The preview's
// existing timeout remains the bounded failure path.
move.acknowledged = false;
continue;
}
if (move.live_dirty) {
move_existing(move.entity, move.object);
move.live_dirty = false;
move.acknowledged = false;
tx.sent_at = GetTickCount64();
} else if (!move.acknowledged) {
move.acknowledged = object_has_pose(move.entity, move.object);
if (!move.acknowledged &&
move_created_duplicate(move.object, preview.identities, *live)) {
fail("Moving an object created a duplicate. The edit was not saved.");
return;
}
}
}
tx.sent = true;
return;
}
if (step.kind != Kind::move || step.acknowledged)
return;
const auto live = placement_live_entities(placements_runtime().manager);
if (!live || !live->contains(step.entity)) return;
if (!step.identities_captured) {
step.before_entities = *live;
step.identities_captured = true;
}
if (!tx.sent) {
tx.sent = true;
tx.sent_at = GetTickCount64();
move_existing(step.entity, step.object);
return; // Read back next tick, after native systems run.
}
step.acknowledged = object_has_pose(step.entity, step.object);
if (!step.acknowledged &&
move_created_duplicate(step.object, step.before_entities, *live))
fail("Moving an object created a duplicate. The edit was not saved.");
} catch (const std::exception &error) {
fail(error.what());
} catch (...) {
fail("Native object movement failed.");
}
}
void update_park_editor() {
auto &e = editor_state();
auto &r = placements_runtime();
refresh_catalog();
if (!placement_session_ready())
return;
expire_preview();
update_highlights();
if (const auto probe = std::exchange(e.probe, std::nullopt); probe && probe->generation == e.generation) {
if (e.transaction && e.transaction->preview && e.transaction->sent && !e.transaction->steps.empty() &&
e.transaction->steps.front().kind == Kind::create && !e.transaction->steps.front().entity) {
// The preview may have collision before its creation callback arrives.
// Keep the cursor sample pending until we know which body to exclude.
e.probe = probe;
} else {
if (const auto table = placement_manager_ids(r.manager)) {
std::map<std::uint32_t, std::uint64_t> native_drops;
for (const auto &row : *table)
native_drops.emplace(row.id, row.entity);
e.native_drops = std::move(native_drops);
}
// The native dropper queries the client physics world. The server map
// context has the same component type but its world at +0x40 is null.
// Resolve the current client TLS context here, on the client update.
std::array<std::uintptr_t, 2> context{};
if (r.context)
r.context(context.data());
std::vector<std::uint64_t> ignored;
std::vector<editor::SurfaceBody> ignored_bodies;
if (!probe->picking && e.transaction && e.transaction->preview)
for (const auto &step : e.transaction->steps)
if (step.entity) {
ignored.push_back(step.entity);
const auto client = client_root(step.entity);
if (client && e.native_ready && e.physics_query) {
alignas(16) std::array<std::uint64_t, 2> root{};
alignas(16) std::array<std::uint64_t, 5> component{};
r.resolve(root.data(), client, false);
if (root[0] != client || !r.valid(root.data()))
continue;
e.physics_query(root.data(), component.data());
editor::SurfaceBody body{};
if (component[2] && component[3] && memory::read(component[3], body) &&
e.picking_api.body_valid(body.data()))
ignored_bodies.push_back(body);
}
}
e.surface = editor::probe_surface(e.surface_api, context[0], *probe, &surface_owner, ignored,
ignored_bodies, &surface_body);
if (probe->picking)
for (const auto &row : r.rows)
if (row.spawned && row.entity == e.surface.entity)
e.surface.object = row.saved_id;
if (probe->picking && !e.picking_api.ready)
e.surface.available = false;
}
}
// placement_session_ready may have reset the editor after a world transition.
if (!e.transaction)
return;
auto &tx = *e.transaction;
if (tx.steps.empty()) {
if (tx.preview && same_layout(tx.change.before, tx.change.after)) {
const bool cancelled = tx.preview->cancelled;
e.transaction.reset();
++e.revision;
e.status = cancelled ? "Preview cancelled." : "No changes.";
return; // Cancel/no-op preserves autosave and undo/redo history.
}
r.document.maps[r.map] = tx.change.after;
for (const auto &object : tx.change.after)
r.next_id = std::max(r.next_id, object.id + 1);
if (tx.history < 0) {
e.redo.push_back(e.undo.back());
e.undo.pop_back();
} else if (tx.history > 0) {
e.undo.push_back(e.redo.back());
e.redo.pop_back();
} else {
e.undo.push_back(tx.change);
e.redo.clear();
if (e.undo.size() > 64)
e.undo.pop_front();
}
e.transaction.reset();
++e.revision;
e.status = r.personal_document ? "Park edit applied for this session."
: "Park edit applied. Autosave queued.";
if (const auto live = placement_live_entities(r.manager))
reconcile_placement_rows(*live);
queue_placement_save();
return;
}
auto &step = tx.steps.front();
if (tx.preview && step.kind == Kind::move) {
if (tx.preview->ending && std::all_of(tx.steps.begin(), tx.steps.end(), [](const auto &move) {
return !move.live_dirty && move.acknowledged;
})) {
const auto live = placement_live_entities(r.manager);
if (!live) return;
if (std::any_of(tx.steps.begin(), tx.steps.end(), [&](const auto &move) {
return !live->contains(move.entity);
})) return;
for (const auto &move : tx.steps) {
r.tracked[move.entity] = move.object.id;
observe_placement(move.entity, move.object);
}
tx.steps.clear();
} else if (tx.preview->ending && GetTickCount64() > (tx.sent ? tx.sent_at : tx.waiting_since) + 10000)
fail("The game did not acknowledge the final preview transform.");
return;
}
const auto table = placement_manager_ids(r.manager);
if (!table) {
const auto since = tx.sent ? tx.sent_at : tx.waiting_since;
if (GetTickCount64() > since + 10000)
fail("The local object table remained unavailable.");
return;
}
std::optional<std::uint32_t> native_id;
for (const auto &row : *table)
if (row.entity == step.entity)
native_id = row.id;
if (tx.sent) {
const bool complete =
step.kind == Kind::erase ? !native_id : step.acknowledged && native_id.has_value();
if (complete) {
if (tx.preview && step.kind == Kind::create) {
const auto entity = step.entity;
auto identities = step.before_entities;
identities.insert(entity);
step = {Kind::move, tx.preview->target, entity};
step.before_entities = std::move(identities);
step.identities_captured = true;
tx.preview->identities = step.before_entities;
tx.preview->identities_captured = true;
tx.sent = false;
tx.waiting_since = GetTickCount64();
update_preview_target(tx);
return;
}
if (tx.preview && !tx.preview->ending)
return; // Live poses remain temporary until mouse release.
if (step.kind == Kind::erase) {
r.tracked.erase(step.entity);
std::erase_if(r.rows, [&](const auto &row) { return row.entity == step.entity; });
} else {
r.tracked[step.entity] = step.object.id;
observe_placement(step.entity, step.object);
}
tx.steps.pop_front();
tx.sent = false;
tx.waiting_since = GetTickCount64();
return;
}
if (GetTickCount64() > tx.sent_at + 10000)
fail("The game did not acknowledge the edit.");
return;
}
if (step.kind == Kind::move) {
if (GetTickCount64() > tx.waiting_since + 10000)
fail("The local world update became unavailable.");
return; // The server tick owns movement and readback.
}
const auto channel = placement_client_channel();
if (!channel) {
if (GetTickCount64() > tx.waiting_since + 10000)
fail("The local placement channel became unavailable.");
return;
}
if (step.kind == Kind::erase && !native_id) {
// Another native path already removed it. Completing the erase here
// keeps the saved layout and editor transaction in agreement.
r.tracked.erase(step.entity);
std::erase_if(r.rows, [&](const auto &row) { return row.entity == step.entity; });
tx.steps.pop_front();
tx.waiting_since = GetTickCount64();
return;
}
if (step.kind == Kind::move && !native_id) {
if (GetTickCount64() > tx.waiting_since + 10000)
fail("The selected object no longer exists.");
return;
}
tx.sent = true;
tx.sent_at = GetTickCount64();
if (step.kind == Kind::erase)
send_placement_delete(channel, {*native_id});
else {
for (const auto &row : *table)
step.before_entities.insert(row.entity);
if (!r.creation.source()) { fail("Object request IDs exhausted; restart the game."); return; }
if (!queue_placement_create(step.object, cosmetic_runtime().items.at(step.object.item).hash,
r.creation.source())) tx.sent = false;
}
}
} // namespace dingosdk::profile_runtime
namespace dingosdk {
using namespace profile_runtime;
void tick_local_park_editor() noexcept {
PreserveError preserve;
try {
std::lock_guard lock(local_runtime().native_mutex);
if (!local_runtime().active || cosmetic_runtime().update_thread != GetCurrentThreadId())
return;
update_placement_restore();
} catch (...) {
std::lock_guard lock(local_runtime().native_mutex);
fail("Park editing stopped after a native update error.");
}
}
void set_park_mod_list_visible(bool visible) noexcept { park_mod_list_visible.store(visible, std::memory_order_relaxed); }
ParkEditorModel local_park_editor() {
std::lock_guard lock(local_runtime().native_mutex);
const auto &r = placements_runtime();
auto &e = editor_state();
ParkEditorModel model;
model.available = lobby_object_placement_allowed() && local_runtime().active && r.store && !r.failed && !r.save_failed && !r.map.empty() &&
e.native_ready && r.enabled;
model.busy = e.transaction.has_value() || r.clearing || r.inflight.has_value() || !r.restore.empty();
model.failed = e.failed || r.restore_failed;
model.generation = e.generation;
model.revision = e.revision;
model.map = r.map;
model.can_undo = idle() && !e.undo.empty();
model.can_redo = idle() && !e.redo.empty();
model.assets = e.assets;
if (park_mod_list_visible.load(std::memory_order_relaxed) && GetTickCount64() >= e.next_park_scan)
refresh_park_mods();
for (const auto &mod : e.park_mods)
model.park_mods.push_back({mod.folder, mod.details.title, mod.details.author, mod.details.version,
mod.details.description, mod.enabled,
std::find(mod.maps.begin(), mod.maps.end(), r.map) != mod.maps.end(), mod.maps});
model.legacy_parks = e.legacy_parks;
model.project = e.project;
model.can_load_parks = !is_lobby_guest();
model.surface = e.surface;
if (e.transaction && e.transaction->preview)
model.preview_token = e.transaction->preview->token;
model.status = e.status;
if (!model.available)
model.status = !r.enabled ? "Enable object persistence to edit parks."
: "Load a supported local level to edit parks.";
if (!e.native_ready)
model.status = "Park editor move contract is unavailable for this build.";
if (r.save_failed)
model.status = "Object autosave failed. Check objects.sqlite3 before editing again.";
if (!lobby_object_placement_allowed())
model.status = "The host has disabled object placement for you in this session.";
std::set<std::uint64_t> spawned;
for (const auto &row : r.rows)
if (row.spawned)
spawned.insert(row.saved_id);
if (const auto it = r.document.maps.find(r.map); it != r.document.maps.end())
for (const auto &object : it->second) {
model.objects.push_back(
{object.id, object.item, object.position, object.rotation, spawned.contains(object.id), object.scale});
}
return model;
}
} // namespace dingosdk