12#include <unordered_set>
15#include "absl/strings/str_format.h"
26namespace fs = std::filesystem;
35 .
subject =
"custom object asset",
36 .published_file =
"published custom object asset",
40 const fs::path& path) {
41 std::ifstream input(path, std::ios::binary);
42 if (!input.is_open()) {
43 return absl::NotFoundError(absl::StrFormat(
44 "Could not open custom object file: %s", path.string()));
46 std::vector<uint8_t> bytes((std::istreambuf_iterator<char>(input)),
47 std::istreambuf_iterator<char>());
49 return absl::InternalError(absl::StrFormat(
50 "Could not read custom object file: %s", path.string()));
56 auto path_it = path.begin();
57 for (
auto root_it = root.begin(); root_it != root.end();
58 ++root_it, ++path_it) {
59 if (path_it == path.end() || *path_it != *root_it) {
67 if (component.empty() ||
68 std::isspace(
static_cast<unsigned char>(component.front())) ||
69 std::isspace(
static_cast<unsigned char>(component.back())) ||
70 component.back() ==
'.') {
73 for (
const unsigned char character : component) {
74 if (character < 0x20 || character == 0x7F || character ==
',' ||
75 character ==
'<' || character ==
'>' || character ==
':' ||
76 character ==
'"' || character ==
'\\' || character ==
'|' ||
77 character ==
'?' || character ==
'*') {
81 std::string basename(component.substr(0, component.find(
'.')));
82 std::transform(basename.begin(), basename.end(), basename.begin(),
83 [](
unsigned char character) {
84 return static_cast<char>(std::toupper(character));
86 static const std::unordered_set<std::string> kWindowsReservedNames = {
87 "CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
88 "COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
89 "LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
91 return !kWindowsReservedNames.contains(basename);
96 std::vector<CustomObject::TileMapEntry> visible;
97 visible.reserve(
object.tiles.size());
98 for (
const auto& tile :
object.tiles) {
99 if (tile.tile_data != 0) {
100 visible.push_back(tile);
104 visible.begin(), visible.end(), [](
const auto& lhs,
const auto& rhs) {
105 return std::tie(lhs.rel_y, lhs.rel_x) < std::tie(rhs.rel_y, rhs.rel_x);
113 const std::vector<uint8_t>& data) {
115 return absl::DataLossError(
"Custom object data is empty");
119 std::unordered_set<int> occupied_positions;
121 int current_buffer_pos = 0;
122 bool found_terminator =
false;
124 while (cursor + 1 < data.size()) {
125 const uint16_t header =
static_cast<uint16_t
>(data[cursor]) |
126 (
static_cast<uint16_t
>(data[cursor + 1]) << 8);
129 found_terminator =
true;
132 const int encoded_count = header & 0x001F;
133 const int count = encoded_count == 0 ? kMaxSegmentTiles : encoded_count;
137 const int jump_offset = ((header - count) >> 8) & 0xFF;
138 if ((jump_offset & 1) != 0) {
139 return absl::DataLossError(
140 "Custom object segment jump is not tile-aligned");
142 if (cursor +
static_cast<size_t>(count * 2) > data.size()) {
143 return absl::DataLossError(
"Custom object ends inside a tile segment");
146 const int segment_start = current_buffer_pos;
147 for (
int index = 0; index < count; ++index) {
148 if (current_buffer_pos < 0 ||
149 current_buffer_pos + 1 >= kBufferSizeBytes ||
150 (current_buffer_pos & 1) != 0) {
151 return absl::OutOfRangeError(
152 "Custom object tile is outside the 64x64 dungeon buffer");
154 if (!occupied_positions.insert(current_buffer_pos).second) {
155 return absl::DataLossError(
156 "Custom object segments overlap the same tile position");
159 const uint16_t tile_data =
static_cast<uint16_t
>(data[cursor]) |
160 (
static_cast<uint16_t
>(data[cursor + 1]) << 8);
162 object.tiles.push_back({(current_buffer_pos % kBufferStrideBytes) / 2,
163 current_buffer_pos / kBufferStrideBytes,
165 current_buffer_pos += 2;
167 current_buffer_pos = segment_start + jump_offset;
170 if (!found_terminator) {
171 return absl::DataLossError(
"Custom object is missing its terminator");
173 if (cursor != data.size()) {
174 return absl::DataLossError(
175 "Custom object contains trailing bytes after its terminator");
182 if (
object.tiles.empty()) {
183 return std::vector<uint8_t>{0, 0};
190 std::vector<OrderedTile> ordered_tiles;
191 ordered_tiles.reserve(
object.tiles.size());
192 for (
const auto& tile :
object.tiles) {
193 if (tile.rel_x < 0 || tile.rel_x >= kBufferWidthTiles || tile.rel_y < 0 ||
194 tile.rel_y >= kBufferHeightTiles) {
195 return absl::OutOfRangeError(
196 "Custom object tile is outside the 64x64 dungeon buffer");
198 ordered_tiles.push_back(
199 {tile.rel_y * kBufferWidthTiles + tile.rel_x, tile.tile_data});
201 std::sort(ordered_tiles.begin(), ordered_tiles.end(),
202 [](
const OrderedTile& lhs,
const OrderedTile& rhs) {
203 return lhs.position < rhs.position;
205 for (
size_t index = 1; index < ordered_tiles.size(); ++index) {
206 if (ordered_tiles[index - 1].position == ordered_tiles[index].position) {
207 return absl::InvalidArgumentError(
208 "Custom object contains duplicate tile positions");
213 std::vector<uint16_t> tile_words;
215 std::vector<Segment> segments;
216 size_t tile_index = 0;
217 int segment_start = 0;
218 while (tile_index < ordered_tiles.size()) {
219 Segment segment{segment_start, {}};
220 if (ordered_tiles[tile_index].position == segment_start) {
221 int next_position = segment_start;
222 while (tile_index < ordered_tiles.size() &&
223 ordered_tiles[tile_index].position == next_position &&
224 segment.tile_words.size() < kMaxSegmentTiles) {
225 segment.tile_words.push_back(ordered_tiles[tile_index].tile_data);
233 segment.tile_words.push_back(0);
235 segments.push_back(std::move(segment));
237 if (tile_index >= ordered_tiles.size()) {
240 const int next_target = ordered_tiles[tile_index].position;
241 const int minimum_next_start =
242 segment_start +
static_cast<int>(segments.back().tile_words.size());
243 const int maximum_next_start = segment_start + 0x7F;
244 segment_start = std::min(next_target, maximum_next_start);
245 if (segment_start < minimum_next_start) {
246 return absl::DataLossError(
247 "Custom object segment planner produced an overlapping jump");
251 std::vector<uint8_t> binary;
252 binary.reserve(
object.tiles.size() * 2 + segments.size() * 2 + 2);
253 for (
size_t index = 0; index < segments.size(); ++index) {
255 if (index + 1 < segments.size()) {
256 jump_bytes = (segments[index + 1].start_position -
257 segments[index].start_position) *
259 if (jump_bytes <= 0 || jump_bytes > 0xFE) {
260 return absl::DataLossError(
261 "Custom object segment planner exceeded the runtime jump range");
264 const bool encodes_thirty_two_tiles =
265 segments[index].tile_words.size() == kMaxSegmentTiles;
266 int stored_jump = jump_bytes;
267 if (encodes_thirty_two_tiles) {
269 if (stored_jump > 0xFF) {
270 return absl::InvalidArgumentError(
271 "Custom object gap after a 32-tile segment cannot be represented "
272 "by its adjusted 8-bit jump");
275 const uint16_t encoded_count =
276 static_cast<uint16_t
>(segments[index].tile_words.size()) & 0x001F;
277 const uint16_t header =
278 encoded_count | (
static_cast<uint16_t
>(stored_jump) << 8);
279 binary.push_back(
static_cast<uint8_t
>(header & 0xFF));
280 binary.push_back(
static_cast<uint8_t
>(header >> 8));
281 for (uint16_t tile_word : segments[index].tile_words) {
282 binary.push_back(
static_cast<uint8_t
>(tile_word & 0xFF));
283 binary.push_back(
static_cast<uint8_t
>(tile_word >> 8));
290 if (!decoded_or.ok()) {
291 return absl::DataLossError(
292 absl::StrFormat(
"Encoded custom object failed strict validation: %s",
293 decoded_or.status().message()));
295 if (VisibleRuntimeTiles(*decoded_or) != VisibleRuntimeTiles(
object)) {
296 return absl::DataLossError(
297 "Encoded custom object did not preserve its visible runtime layout");
303 const std::string& custom_objects_folder,
const std::string& filename) {
304 if (custom_objects_folder.empty()) {
305 return absl::FailedPreconditionError(
306 "Custom objects folder is not configured");
308 if (filename.empty()) {
309 return absl::InvalidArgumentError(
"Custom object filename is empty");
313 if (filename.find(
'\\') != std::string::npos) {
314 return absl::InvalidArgumentError(
315 "Custom object filename must use forward slashes");
318 const fs::path relative_path(filename);
319 if (relative_path.is_absolute() || relative_path.has_root_directory() ||
320 relative_path.has_root_name()) {
321 return absl::InvalidArgumentError(
322 "Custom object filename must be project-relative");
324 for (
const auto& component : relative_path) {
325 if (component ==
"..") {
326 return absl::InvalidArgumentError(
327 "Custom object filename cannot contain parent traversal");
329 if (component !=
"." &&
330 !IsPortableAssetComponent(component.generic_string())) {
331 return absl::InvalidArgumentError(
332 "Custom object filename contains a non-portable path component");
335 const fs::path normalized = relative_path.lexically_normal();
336 if (normalized.empty() || normalized.filename().empty() ||
337 normalized.extension() !=
".bin") {
338 return absl::InvalidArgumentError(
339 "Custom object filename must end in .bin");
342 std::error_code canonical_error;
343 const fs::path canonical_root =
344 fs::canonical(fs::path(custom_objects_folder), canonical_error);
345 if (canonical_error || !fs::is_directory(canonical_root, canonical_error) ||
347 return absl::FailedPreconditionError(absl::StrFormat(
348 "Custom objects folder is unavailable: %s", custom_objects_folder));
350 const fs::path candidate_parent = canonical_root / normalized.parent_path();
351 const fs::path canonical_parent =
352 fs::canonical(candidate_parent, canonical_error);
353 if (canonical_error || !fs::is_directory(canonical_parent, canonical_error) ||
355 return absl::FailedPreconditionError(
356 absl::StrFormat(
"Custom object parent folder is unavailable: %s",
357 candidate_parent.string()));
359 if (!PathStartsWith(canonical_parent, canonical_root)) {
360 return absl::PermissionDeniedError(
361 "Custom object filename resolves outside the configured folder");
364 const fs::path target = canonical_parent / normalized.filename();
365 std::error_code exists_error;
366 const bool target_exists = fs::exists(target, exists_error);
368 return absl::FailedPreconditionError(
369 absl::StrFormat(
"Could not inspect custom object target %s: %s",
370 target.string(), exists_error.message()));
373 const fs::file_status status = fs::symlink_status(target, exists_error);
374 if (exists_error || fs::is_symlink(status) ||
375 !fs::is_regular_file(status)) {
376 return absl::PermissionDeniedError(
377 "Custom object target must be a regular non-symlink file");
379 const fs::path canonical_target = fs::canonical(target, exists_error);
380 if (exists_error || !PathStartsWith(canonical_target, canonical_root)) {
381 return absl::PermissionDeniedError(
382 "Custom object target resolves outside the configured folder");
384 return canonical_target;
390 const std::string& custom_objects_folder,
const std::string& filename) {
391 fs::path resolved_path;
393 custom_objects_folder, filename));
394 std::vector<uint8_t> source_bytes;
399 .source_bytes = std::move(source_bytes),
400 .resolved_path = std::move(resolved_path)};
404 const std::string& custom_objects_folder,
const std::string& filename,
406 const std::vector<uint8_t>& expected_source_bytes,
407 const fs::path& expected_resolved_path) {
408#if defined(__EMSCRIPTEN__)
409 return absl::FailedPreconditionError(
410 "Custom object publishing is unavailable in browser builds because "
411 "durable atomic project-file publication cannot be guaranteed");
413 if (expected_source_bytes.empty()) {
414 return absl::FailedPreconditionError(
415 "Custom object editing requires an exact source-byte snapshot");
417 if (expected_resolved_path.empty()) {
418 return absl::FailedPreconditionError(
419 "Custom object editing requires its original resolved asset path");
424 std::vector<uint8_t> binary;
429 if (target != expected_resolved_path) {
430 return absl::AbortedError(
431 "Custom object project or asset path changed after it was opened; "
435 std::unique_ptr<core::SourceArtifactPublicationLock> publication_lock;
438 {target}, kCustomObjectPublisherLabels));
440 fs::path locked_target;
442 custom_objects_folder, filename));
443 if (locked_target != target) {
444 return absl::AbortedError(
445 "Custom object path changed while acquiring its publication lock");
447 std::vector<uint8_t> source_before;
449 if (source_before != expected_source_bytes) {
450 return absl::AbortedError(
451 "Custom object source changed after it was opened; edits were kept");
454 const std::string before(source_before.begin(), source_before.end());
455 const std::string after(binary.begin(), binary.end());
459 {{.target = locked_target, .before = before, .after = after}},
460 expected_sha256, [&]() -> absl::Status {
461 std::vector<uint8_t> reopened;
463 if (reopened != binary) {
464 return absl::DataLossError(
465 "Published custom object failed exact byte readback");
469 if (VisibleRuntimeTiles(decoded) != VisibleRuntimeTiles(
object)) {
470 return absl::DataLossError(
471 "Published custom object changed its visible runtime layout");
473 return absl::OkStatus();
480 if (source_word == 0) {
486 if (object_id == 0x54) {
487 return source_word | 0x0300;
497 "track_corner_TL.bin",
498 "track_corner_TR.bin",
499 "track_corner_BL.bin",
500 "track_corner_BR.bin",
501 "track_floor_UD.bin",
502 "track_floor_LR.bin",
503 "track_floor_corner_TL.bin",
504 "track_floor_corner_TR.bin",
505 "track_floor_corner_BL.bin",
506 "track_floor_corner_BR.bin",
507 "track_floor_any.bin",
508 "wall_sword_house.bin",
521 "manhandla_body_1a.bin",
554 context.cache.clear();
563 LOG_INFO(
"CustomObjectManager",
"Initialize: base_path='%s'",
566 LOG_INFO(
"CustomObjectManager",
"Object 0x31 file list has %zu entries",
573 const std::unordered_map<
int, std::vector<std::string>>& map) {
588 const State& state) {
590 auto& context = context_it->second;
591 if (inserted || context.state != state) {
592 context.state = state;
593 context.cache.clear();
612 int object_id)
const {
614 auto custom_it = custom_file_map.find(object_id);
615 if (custom_it != custom_file_map.end()) {
616 return &custom_it->second;
618 if (object_id == 0x31) {
621 if (object_id == 0x32) {
624 if (object_id == 0x54) {
631 const std::string& filename) {
633 if (
const auto cached = cache.find(filename); cached != cache.end()) {
634 return cached->second;
638 if (!asset_or.ok()) {
640 asset_or.status().ToString().c_str());
641 cache.emplace(filename, asset_or.status());
642 return asset_or.status();
645 auto object_ptr = std::make_shared<CustomObject>(std::move(asset_or->object));
646 cache.emplace(filename, object_ptr);
651absl::StatusOr<std::shared_ptr<CustomObject>>
655 return absl::NotFoundError(
"Object ID not mapped to custom object");
659 if (subtype < 0 || (runtime_count > 0 && subtype >= runtime_count) ||
660 subtype >=
static_cast<int>(list->size())) {
661 return absl::OutOfRangeError(
"Subtype index out of range");
669 if (runtime_count > 0) {
670 return runtime_count;
673 return static_cast<int>(list->size());
683 static constexpr std::array<int, 3> kRuntimeObjectIds = {0x31, 0x32, 0x54};
684 return kRuntimeObjectIds;
688 int object_id)
const {
695const std::vector<std::string>&
697 static const std::vector<std::string> kEmpty;
698 if (object_id == 0x31) {
701 if (object_id == 0x32) {
704 if (object_id == 0x54) {
715 int object_id,
int subtype)
const {
717 if (runtime_count <= 0) {
718 return absl::NotFoundError(
"Object ID has no fixed custom runtime slots");
720 if (subtype < 0 || subtype >= runtime_count) {
721 return absl::OutOfRangeError(
"Custom runtime subtype is out of range");
726 const auto mapped_it = custom_file_map.find(object_id);
727 if (mapped_it == custom_file_map.end()) {
729 binding.
filename = defaults[
static_cast<size_t>(subtype)];
731 }
else if (subtype >=
static_cast<int>(mapped_it->second.size()) ||
732 mapped_it->second[
static_cast<size_t>(subtype)].empty()) {
735 binding.
filename = mapped_it->second[
static_cast<size_t>(subtype)];
745 return binding.ok() ? binding->filename :
"";
749 if (list && subtype >= 0 && (runtime_count == 0 || subtype < runtime_count) &&
750 subtype <
static_cast<int>(list->size())) {
751 return (*list)[subtype];
Manages loading and caching of custom object binary files.
RuntimeContext & ActiveContext()
int GetSubtypeCount(int object_id) const
void RestoreState(const State &state)
std::unordered_map< uint64_t, RuntimeContext > runtime_contexts_
absl::StatusOr< CustomObjectSlotBinding > ResolveSlotBinding(int object_id, int subtype) const
uint64_t asset_generation() const
bool HasCustomFileMap() const
const std::string & GetBasePath() const
RuntimeContext standalone_context_
static const std::array< int, 3 > & RuntimeObjectIds()
static const std::vector< std::string > kSubtype54Filenames
void RemoveRuntimeContext(uint64_t context_id)
static const std::vector< std::string > & DefaultSubtypeFilenamesForObject(int object_id)
void SetObjectFileMap(const std::unordered_map< int, std::vector< std::string > > &map)
static int RuntimeSubtypeCountForObject(int object_id)
std::optional< uint64_t > active_runtime_context_id_
static CustomObjectManager & Get()
uint64_t next_asset_generation_
absl::StatusOr< std::shared_ptr< CustomObject > > LoadObject(const std::string &filename)
absl::StatusOr< std::shared_ptr< CustomObject > > GetObjectInternal(int object_id, int subtype)
static const std::vector< std::string > kSubtype2Filenames
void Initialize(const std::string &custom_objects_folder)
const std::vector< std::string > * ResolveFileList(int object_id) const
void ClearObjectFileMap()
uint64_t NextAssetGeneration()
std::string ResolveFilename(int object_id, int subtype) const
static const std::vector< std::string > kSubtype1Filenames
void ActivateRuntimeContext(uint64_t context_id, const State &state)
void ActivateStandaloneContext()
std::vector< std::string > GetEffectiveFileList(int object_id) const
State SnapshotState() const
static ObjectGeometry & Get()
#define LOG_ERROR(category, format,...)
#define LOG_INFO(category, format,...)
#define ASSIGN_OR_RETURN(type_variable_name, expression)
absl::Status PublishSourceArtifacts(const SourceArtifactPublicationLock &lock, std::vector< SourceArtifactUpdate > updates, std::string_view expected_primary_sha256, SourceArtifactReadbackValidator readback_validator)
std::string ComputeSourceArtifactSha256(std::string_view content)
absl::StatusOr< std::unique_ptr< SourceArtifactPublicationLock > > AcquireSourceArtifactPublicationLock(const std::vector< fs::path > &targets, const SourceArtifactPublisherLabels &labels)
constexpr int kBufferWidthTiles
bool PathStartsWith(const fs::path &path, const fs::path &root)
absl::StatusOr< std::vector< uint8_t > > ReadBinaryFileAtPath(const fs::path &path)
std::vector< CustomObject::TileMapEntry > VisibleRuntimeTiles(const CustomObject &object)
constexpr int kMaxSegmentTiles
constexpr int kBufferHeightTiles
bool IsPortableAssetComponent(std::string_view component)
constexpr int kBufferStrideBytes
const core::SourceArtifactPublisherLabels kCustomObjectPublisherLabels
constexpr int kBufferSizeBytes
absl::StatusOr< CustomObject > DecodeCustomObjectBinary(const std::vector< uint8_t > &data)
absl::StatusOr< std::vector< uint8_t > > EncodeCustomObjectBinary(const CustomObject &object)
@ kConfiguredSlotUnmapped
uint16_t CustomObjectRuntimeTileWord(int object_id, uint16_t source_word)
absl::StatusOr< fs::path > ResolveCustomObjectAssetPath(const std::string &custom_objects_folder, const std::string &filename)
absl::StatusOr< std::vector< uint8_t > > PublishCustomObjectBinary(const std::string &custom_objects_folder, const std::string &filename, const CustomObject &object, const std::vector< uint8_t > &expected_source_bytes, const fs::path &expected_resolved_path)
absl::StatusOr< CustomObjectAsset > LoadCustomObjectAsset(const std::string &custom_objects_folder, const std::string &filename)
#define RETURN_IF_ERROR(expr)
std::unordered_map< std::string, absl::StatusOr< std::shared_ptr< CustomObject > > > cache
uint64_t asset_generation
std::unordered_map< int, std::vector< std::string > > custom_file_map
CustomObjectMappingOrigin origin
Represents a decoded custom object (from binary format)