yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
hack_manifest.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <cctype>
6#include <filesystem>
7#include <fstream>
8#include <iterator>
9#include <limits>
10#include <sstream>
11#include <utility>
12
13#include "absl/status/statusor.h"
14#include "absl/strings/str_format.h"
15#include "rom/snes.h"
16#include "util/json.h"
17#include "util/log.h"
18#include "util/macro.h"
19
20namespace yaze::core {
21
23 switch (ownership) {
25 return "vanilla_safe";
27 return "hook_patched";
29 return "asm_owned";
31 return "shared";
33 return "asm_expansion";
35 return "ram";
37 return "mirror";
38 }
39 return "unknown";
40}
41
42namespace {
43
44absl::StatusOr<uint32_t> ParseHexAddress(const std::string& str) {
45 try {
46 if (str.size() >= 2 && str[0] == '0' && (str[1] == 'x' || str[1] == 'X')) {
47 return static_cast<uint32_t>(std::stoul(str.substr(2), nullptr, 16));
48 }
49 return static_cast<uint32_t>(std::stoul(str, nullptr, 16));
50 } catch (const std::exception& exc) {
51 return absl::InvalidArgumentError(
52 absl::StrFormat("Invalid hex address '%s': %s", str, exc.what()));
53 }
54}
55
56absl::StatusOr<AddressOwnership> ParseOwnership(const std::string& str) {
57 if (str == "vanilla_safe")
59 if (str == "hook_patched")
61 if (str == "asm_owned")
63 if (str == "shared")
65 if (str == "asm_expansion")
67 if (str == "ram")
69 if (str == "mirror")
71 return absl::InvalidArgumentError(
72 absl::StrFormat("Unknown ownership string '%s'", str));
73}
74
76 switch (ownership) {
81 return true;
85 return false;
86 }
87 return false;
88}
89
90uint32_t NormalizeSnesAddress(uint32_t address) {
91 // Treat FastROM mirrors ($80-$FF) as equivalent to the canonical banks
92 // ($00-$7F). The hack manifest is emitted from ASM `org $XXXXXX` directives
93 // and typically uses canonical addresses.
94 if (address >= 0x800000 && address <= 0xFFFFFF) {
95 address &= 0x7FFFFF;
96 }
97 return address;
98}
99
100constexpr uint64_t kCanonicalLoRomSize = 0x400000;
101
103 switch (stream) {
105 return "objects";
107 return "sprites";
109 return "pot_items";
110 }
111 return "unknown";
112}
113
114absl::StatusOr<uint32_t> ParseStrictHexValue(const Json& value,
115 const std::string& field,
116 uint32_t maximum) {
117 if (!value.is_string()) {
118 return absl::InvalidArgumentError(
119 absl::StrFormat("%s must be a hexadecimal string", field));
120 }
121
122 const std::string input = value.get<std::string>();
123 size_t digits_begin = 0;
124 if (!input.empty() && input.front() == '$') {
125 digits_begin = 1;
126 } else if (input.size() >= 2 && input[0] == '0' &&
127 (input[1] == 'x' || input[1] == 'X')) {
128 digits_begin = 2;
129 }
130 if (digits_begin == input.size()) {
131 return absl::InvalidArgumentError(
132 absl::StrFormat("%s has no hexadecimal digits", field));
133 }
134 for (size_t index = digits_begin; index < input.size(); ++index) {
135 if (!std::isxdigit(static_cast<unsigned char>(input[index]))) {
136 return absl::InvalidArgumentError(absl::StrFormat(
137 "%s contains an invalid hexadecimal value '%s'", field, input));
138 }
139 }
140
141 try {
142 const uint64_t parsed =
143 std::stoull(input.substr(digits_begin), nullptr, 16);
144 if (parsed > maximum) {
145 return absl::InvalidArgumentError(
146 absl::StrFormat("%s value '%s' exceeds 0x%X", field, input, maximum));
147 }
148 return static_cast<uint32_t>(parsed);
149 } catch (const std::exception& exc) {
150 return absl::InvalidArgumentError(
151 absl::StrFormat("Invalid hexadecimal value for %s ('%s'): %s", field,
152 input, exc.what()));
153 }
154}
155
156absl::StatusOr<int> ParseManifestVersion(const Json& root) {
157 if (!root.contains("manifest_version")) {
158 return 0;
159 }
160
161 const Json& value = root["manifest_version"];
162 uint64_t version = 0;
163 if (value.is_number_unsigned()) {
164 version = value.get<uint64_t>();
165 } else if (value.is_number_integer()) {
166 const int64_t signed_version = value.get<int64_t>();
167 if (signed_version < 0) {
168 return absl::InvalidArgumentError(
169 "manifest_version must be a non-negative integer");
170 }
171 version = static_cast<uint64_t>(signed_version);
172 } else {
173 return absl::InvalidArgumentError(
174 "manifest_version must be a non-negative integer");
175 }
176
177 if (version > static_cast<uint64_t>(std::numeric_limits<int>::max())) {
178 return absl::InvalidArgumentError("manifest_version is too large");
179 }
180 return static_cast<int>(version);
181}
182
183absl::StatusOr<int> ParseBoundedNonnegativeInteger(const Json& value,
184 absl::string_view field,
185 uint64_t maximum) {
186 uint64_t parsed = 0;
187 if (value.is_number_unsigned()) {
188 parsed = value.get<uint64_t>();
189 } else if (value.is_number_integer()) {
190 const int64_t signed_value = value.get<int64_t>();
191 if (signed_value < 0) {
192 return absl::InvalidArgumentError(
193 absl::StrFormat("%s must be a non-negative integer", field));
194 }
195 parsed = static_cast<uint64_t>(signed_value);
196 } else {
197 return absl::InvalidArgumentError(
198 absl::StrFormat("%s must be a non-negative integer", field));
199 }
200 if (parsed > maximum ||
201 parsed > static_cast<uint64_t>(std::numeric_limits<int>::max())) {
202 return absl::InvalidArgumentError(
203 absl::StrFormat("%s is too large", field));
204 }
205 return static_cast<int>(parsed);
206}
207
208absl::StatusOr<uint32_t> ParseLoRomAddress(const Json& object,
209 const std::string& key,
210 const std::string& path) {
211 if (!object.contains(key)) {
212 return absl::InvalidArgumentError(
213 absl::StrFormat("%s.%s is required", path, key));
214 }
215
216 uint32_t address = 0;
217 ASSIGN_OR_RETURN(address, ParseStrictHexValue(object[key], path + "." + key,
218 /*maximum=*/0xFFFFFF));
219 address = NormalizeSnesAddress(address);
220 const uint8_t bank = static_cast<uint8_t>((address >> 16) & 0xFF);
221 if (bank == 0x7E || bank == 0x7F) {
222 return absl::InvalidArgumentError(absl::StrFormat(
223 "%s.%s must not use SNES WRAM bank 0x%02X", path, key, bank));
224 }
225 if ((address & 0xFFFF) < 0x8000) {
226 return absl::InvalidArgumentError(absl::StrFormat(
227 "%s.%s must be a mapped LoROM address (low word >= 0x8000)", path,
228 key));
229 }
230 if (NormalizeSnesAddress(PcToSnes(SnesToPc(address))) != address) {
231 return absl::InvalidArgumentError(absl::StrFormat(
232 "%s.%s is not convertible to a canonical LoROM address", path, key));
233 }
234 return address;
235}
236
237absl::StatusOr<uint32_t> ParsePointerCount(const Json& object,
238 const std::string& path) {
239 constexpr const char* kField = "pointer_count";
240 constexpr uint32_t kMaxDungeonPointerCount = 296;
241 if (!object.contains(kField)) {
242 return absl::InvalidArgumentError(
243 absl::StrFormat("%s.%s is required", path, kField));
244 }
245
246 const Json& value = object[kField];
247 uint64_t count = 0;
248 if (value.is_number_unsigned()) {
249 count = value.get<uint64_t>();
250 } else if (value.is_number_integer()) {
251 const int64_t signed_count = value.get<int64_t>();
252 if (signed_count <= 0) {
253 return absl::InvalidArgumentError(
254 absl::StrFormat("%s.%s must be greater than zero", path, kField));
255 }
256 count = static_cast<uint64_t>(signed_count);
257 } else {
258 return absl::InvalidArgumentError(
259 absl::StrFormat("%s.%s must be an integer", path, kField));
260 }
261
262 if (count == 0 || count > kMaxDungeonPointerCount) {
263 return absl::InvalidArgumentError(absl::StrFormat(
264 "%s.%s must be in [1, %u]", path, kField, kMaxDungeonPointerCount));
265 }
266 return static_cast<uint32_t>(count);
267}
268
269absl::StatusOr<DungeonPointerEncoding> ParsePointerEncoding(
270 const Json& object, const std::string& path) {
271 constexpr const char* kField = "pointer_encoding";
272 if (!object.contains(kField) || !object[kField].is_string()) {
273 return absl::InvalidArgumentError(
274 absl::StrFormat("%s.%s must be 'long24' or 'bank16'", path, kField));
275 }
276 const std::string value = object[kField].get<std::string>();
277 if (value == "long24") {
279 }
280 if (value == "bank16") {
282 }
283 return absl::InvalidArgumentError(
284 absl::StrFormat("%s.%s has unknown encoding '%s'", path, kField, value));
285}
286
287absl::StatusOr<DungeonWriteStrategy> ParseWriteStrategy(
288 const Json& object, const std::string& path) {
289 constexpr const char* kField = "strategy";
290 if (!object.contains(kField) || !object[kField].is_string()) {
291 return absl::InvalidArgumentError(absl::StrFormat(
292 "%s.%s must be 'copy_on_write' or 'repack_all'", path, kField));
293 }
294 const std::string value = object[kField].get<std::string>();
295 if (value == "copy_on_write") {
297 }
298 if (value == "repack_all") {
300 }
301 return absl::InvalidArgumentError(
302 absl::StrFormat("%s.%s has unknown strategy '%s'", path, kField, value));
303}
304
306 uint32_t start;
307 uint32_t end;
308 std::string name;
309};
310
311absl::Status ValidateDisjointRanges(std::vector<NamedPcRange> ranges,
312 const std::string& description) {
313 std::sort(ranges.begin(), ranges.end(),
314 [](const NamedPcRange& lhs, const NamedPcRange& rhs) {
315 return lhs.start < rhs.start ||
316 (lhs.start == rhs.start && lhs.end < rhs.end);
317 });
318 if (ranges.empty()) {
319 return absl::OkStatus();
320 }
321
322 NamedPcRange active = ranges.front();
323 for (size_t index = 1; index < ranges.size(); ++index) {
324 const NamedPcRange& current = ranges[index];
325 if (current.start < active.end) {
326 return absl::InvalidArgumentError(
327 absl::StrFormat("%s overlap: %s conflicts with %s", description,
328 current.name, active.name));
329 }
330 if (current.end > active.end) {
331 active = current;
332 }
333 }
334 return absl::OkStatus();
335}
336
337absl::StatusOr<std::vector<SnesAddressRange>> ParseAddressRanges(
338 const Json& object, const std::string& key, const std::string& path) {
339 const std::string field_path = path + "." + key;
340 if (!object.contains(key) || !object[key].is_array() || object[key].empty()) {
341 return absl::InvalidArgumentError(
342 absl::StrFormat("%s must be a non-empty array", field_path));
343 }
344
345 std::vector<SnesAddressRange> ranges;
346 std::vector<NamedPcRange> pc_ranges;
347 ranges.reserve(object[key].size());
348 pc_ranges.reserve(object[key].size());
349 size_t index = 0;
350 for (const Json& range_json : object[key]) {
351 const std::string range_path =
352 absl::StrFormat("%s[%zu]", field_path, index);
353 if (!range_json.is_object()) {
354 return absl::InvalidArgumentError(
355 absl::StrFormat("%s must be an object", range_path));
356 }
357
358 SnesAddressRange range;
360 ParseLoRomAddress(range_json, "start", range_path));
361 ASSIGN_OR_RETURN(range.end,
362 ParseLoRomAddress(range_json, "end", range_path));
363 const uint32_t pc_start = SnesToPc(range.start);
364 const uint32_t pc_end = SnesToPc(range.end);
365 if (range.end <= range.start || pc_end <= pc_start) {
366 return absl::InvalidArgumentError(absl::StrFormat(
367 "%s must be a non-empty half-open range with end > start",
368 range_path));
369 }
370
371 ranges.push_back(range);
372 pc_ranges.push_back({pc_start, pc_end, range_path});
373 ++index;
374 }
375
376 RETURN_IF_ERROR(ValidateDisjointRanges(std::move(pc_ranges), field_path));
377 std::sort(ranges.begin(), ranges.end(),
378 [](const SnesAddressRange& lhs, const SnesAddressRange& rhs) {
379 return lhs.start < rhs.start;
380 });
381 return ranges;
382}
383
384absl::StatusOr<std::vector<SnesAddressRange>> ParseEditorManagedRegions(
385 const Json& root, int manifest_version) {
386 constexpr const char* kSection = "editor_managed_regions";
387 if (!root.contains(kSection)) {
388 return std::vector<SnesAddressRange>{};
389 }
390 if (manifest_version < 3) {
391 return absl::InvalidArgumentError(
392 "editor_managed_regions requires manifest_version 3 or newer");
393 }
394
395 const Json& section = root[kSection];
396 if (!section.is_object() || section.empty()) {
397 return absl::InvalidArgumentError(
398 "editor_managed_regions must be a non-empty object");
399 }
400 return ParseAddressRanges(section, "regions", kSection);
401}
402
404 int total_hooks = 0;
405 std::vector<ProtectedRegion> regions;
406};
407
408absl::StatusOr<uint32_t> ParseProtectedAddress(const Json& object,
409 const std::string& key,
410 const std::string& path,
411 bool require_mapped_lorom) {
412 if (require_mapped_lorom) {
413 return ParseLoRomAddress(object, key, path);
414 }
415 if (!object.contains(key)) {
416 return absl::InvalidArgumentError(
417 absl::StrFormat("%s.%s is required", path, key));
418 }
419
420 uint32_t address = 0;
421 ASSIGN_OR_RETURN(address, ParseStrictHexValue(object[key], path + "." + key,
422 /*maximum=*/0xFFFFFF));
423 return NormalizeSnesAddress(address);
424}
425
426absl::StatusOr<ParsedProtectedRegions> ParseProtectedRegions(
427 const Json& root, int manifest_version) {
429 constexpr const char* kSection = "protected_regions";
430 if (!root.contains(kSection)) {
431 return parsed;
432 }
433
434 const Json& section = root[kSection];
435 if (!section.is_object()) {
436 return absl::InvalidArgumentError("protected_regions must be an object");
437 }
438 if (!section.contains("regions") || !section["regions"].is_array()) {
439 return absl::InvalidArgumentError(
440 "protected_regions.regions must be an array");
441 }
442
443 parsed.total_hooks = section.value("total_hooks", 0);
444 parsed.regions.reserve(section["regions"].size());
445 // Version 3 is the first schema that can exempt editor-managed ranges, so
446 // its protected endpoints must be canonical mapped LoROM addresses. Legacy
447 // manifests still require structurally valid ranges, but retain low-half
448 // addresses emitted by older Oracle sources (for example $1E7F21).
449 const bool require_mapped_lorom = manifest_version >= 3;
450 size_t index = 0;
451 for (const Json& region_json : section["regions"]) {
452 const std::string path =
453 absl::StrFormat("protected_regions.regions[%zu]", index);
454 if (!region_json.is_object()) {
455 return absl::InvalidArgumentError(
456 absl::StrFormat("%s must be an object", path));
457 }
458
459 ProtectedRegion region;
460 ASSIGN_OR_RETURN(region.start,
461 ParseProtectedAddress(region_json, "start", path,
462 require_mapped_lorom));
463 ASSIGN_OR_RETURN(region.end, ParseProtectedAddress(region_json, "end", path,
464 require_mapped_lorom));
465 if (region.end <= region.start ||
466 SnesToPc(region.end) <= SnesToPc(region.start)) {
467 return absl::InvalidArgumentError(absl::StrFormat(
468 "%s must be a non-empty half-open range with end > start", path));
469 }
470
471 region.hook_count = region_json.value("hook_count", 0);
472 region.module = region_json.value("module", "");
473 parsed.regions.push_back(std::move(region));
474 ++index;
475 }
476
477 // Unlike editor-managed and allocator ranges, protected hooks may overlap.
478 // FindProtectedRegion() deliberately walks backward across all candidates.
479 std::sort(parsed.regions.begin(), parsed.regions.end(),
480 [](const ProtectedRegion& lhs, const ProtectedRegion& rhs) {
481 return lhs.start < rhs.start;
482 });
483 return parsed;
484}
485
487 const SnesAddressRange& inner) {
488 return SnesToPc(outer.start) <= SnesToPc(inner.start) &&
489 SnesToPc(inner.end) <= SnesToPc(outer.end);
490}
491
492absl::StatusOr<DungeonStreamLayout> ParseDungeonStreamLayout(
493 DungeonStreamType stream, const Json& json) {
494 const std::string path = absl::StrFormat("dungeon_stream_regions.%s",
496 if (!json.is_object()) {
497 return absl::InvalidArgumentError(
498 absl::StrFormat("%s must be an object", path));
499 }
500
501 DungeonStreamLayout layout;
503 ParseLoRomAddress(json, "pointer_table", path));
508 ParseAddressRanges(json, "data_regions", path));
510 ParseAddressRanges(json, "allocation_regions", path));
511
513 if (!json.contains("pointer_bank")) {
514 return absl::InvalidArgumentError(
515 absl::StrFormat("%s.pointer_bank is required for bank16", path));
516 }
517 uint32_t bank = 0;
519 bank, ParseStrictHexValue(json["pointer_bank"], path + ".pointer_bank",
520 /*maximum=*/0xFF));
521 if (bank >= 0x80) {
522 bank &= 0x7F;
523 }
524 if (bank == 0x7E || bank == 0x7F) {
525 return absl::InvalidArgumentError(absl::StrFormat(
526 "%s.pointer_bank must not use SNES WRAM bank 0x%02X", path, bank));
527 }
528 layout.pointer_bank = static_cast<uint8_t>(bank);
529
530 const uint32_t bank_start_snes = (bank << 16) | 0x8000;
531 const uint32_t bank_start_pc = SnesToPc(bank_start_snes);
532 const uint64_t bank_end_pc = static_cast<uint64_t>(bank_start_pc) + 0x8000;
533 for (const SnesAddressRange& range : layout.data_regions) {
534 if (SnesToPc(range.start) < bank_start_pc ||
535 SnesToPc(range.end) > bank_end_pc) {
536 return absl::InvalidArgumentError(absl::StrFormat(
537 "%s.data_regions must stay within pointer_bank 0x%02X for "
538 "bank16 encoding",
539 path, bank));
540 }
541 }
542 } else if (json.contains("pointer_bank")) {
543 return absl::InvalidArgumentError(
544 absl::StrFormat("%s.pointer_bank is not allowed for long24", path));
545 }
546
547 for (const SnesAddressRange& allocation : layout.allocation_regions) {
548 const bool contained =
549 std::any_of(layout.data_regions.begin(), layout.data_regions.end(),
550 [&allocation](const SnesAddressRange& data) {
551 return PcRangeContains(data, allocation);
552 });
553 if (!contained) {
554 return absl::InvalidArgumentError(absl::StrFormat(
555 "%s allocation range [0x%06X, 0x%06X) is not fully contained in "
556 "a data_region",
557 path, allocation.start, allocation.end));
558 }
559 }
560
561 const uint64_t pointer_width =
563 const uint64_t table_start_pc = SnesToPc(layout.pointer_table);
564 const uint64_t table_size =
565 pointer_width * static_cast<uint64_t>(layout.pointer_count);
566 const uint64_t table_end_pc = table_start_pc + table_size;
567 if (table_start_pc > kCanonicalLoRomSize ||
568 table_end_pc > kCanonicalLoRomSize) {
569 return absl::InvalidArgumentError(absl::StrFormat(
570 "%s pointer table extends beyond canonical LoROM address space", path));
571 }
573 const uint64_t runtime_table_end =
574 static_cast<uint64_t>(layout.pointer_table & 0xFFFFu) + table_size;
575 if (runtime_table_end > 0x10000u) {
576 return absl::InvalidArgumentError(absl::StrFormat(
577 "%s bank16 pointer table crosses its runtime CPU bank", path));
578 }
579 }
580
581 return layout;
582}
583
584absl::StatusOr<std::unordered_map<DungeonStreamType, DungeonStreamLayout>>
586 std::unordered_map<DungeonStreamType, DungeonStreamLayout> layouts;
587 if (!root.contains("dungeon_stream_regions")) {
588 return layouts;
589 }
590
591 const Json& section = root["dungeon_stream_regions"];
592 if (!section.is_object() || section.empty()) {
593 return absl::InvalidArgumentError(
594 "dungeon_stream_regions must be a non-empty object");
595 }
596
597 constexpr std::array<std::pair<const char*, DungeonStreamType>, 3> kStreams =
598 {{{"objects", DungeonStreamType::kObjects},
599 {"sprites", DungeonStreamType::kSprites},
600 {"pot_items", DungeonStreamType::kPotItems}}};
601 for (const auto& item : section.items()) {
602 const auto& key = item.key();
603 bool known = false;
604 for (const auto& entry : kStreams) {
605 if (key == entry.first) {
606 known = true;
607 break;
608 }
609 }
610 if (!known) {
611 return absl::InvalidArgumentError(absl::StrFormat(
612 "dungeon_stream_regions contains unknown stream '%s'", key));
613 }
614 }
615
616 std::vector<NamedPcRange> occupied_ranges;
617 std::vector<NamedPcRange> allocation_ranges;
618 for (const auto& [key, stream] : kStreams) {
619 if (!section.contains(key)) {
620 continue;
621 }
622
623 DungeonStreamLayout layout;
624 ASSIGN_OR_RETURN(layout, ParseDungeonStreamLayout(stream, section[key]));
625
626 const uint64_t pointer_width =
628 const uint64_t table_start_pc = SnesToPc(layout.pointer_table);
629 const uint64_t table_end_pc =
630 table_start_pc +
631 pointer_width * static_cast<uint64_t>(layout.pointer_count);
632 occupied_ranges.push_back({static_cast<uint32_t>(table_start_pc),
633 static_cast<uint32_t>(table_end_pc),
634 absl::StrFormat("%s.pointer_table", key)});
635
636 for (size_t index = 0; index < layout.data_regions.size(); ++index) {
637 const auto& range = layout.data_regions[index];
638 occupied_ranges.push_back(
639 {SnesToPc(range.start), SnesToPc(range.end),
640 absl::StrFormat("%s.data_regions[%zu]", key, index)});
641 }
642 for (size_t index = 0; index < layout.allocation_regions.size(); ++index) {
643 const auto& range = layout.allocation_regions[index];
644 allocation_ranges.push_back(
645 {SnesToPc(range.start), SnesToPc(range.end),
646 absl::StrFormat("%s.allocation_regions[%zu]", key, index)});
647 }
648 layouts.emplace(stream, std::move(layout));
649 }
650
651 RETURN_IF_ERROR(ValidateDisjointRanges(std::move(occupied_ranges),
652 "dungeon stream pointer/data ranges"));
653 RETURN_IF_ERROR(ValidateDisjointRanges(std::move(allocation_ranges),
654 "dungeon stream allocation ranges"));
655 return layouts;
656}
657
658} // namespace
659
661 loaded_ = false;
663 hack_name_.clear();
664 total_hooks_ = 0;
665 protected_regions_.clear();
667 owned_banks_.clear();
668 room_tag_map_.clear();
669 room_tags_.clear();
670 feature_flag_map_.clear();
671 feature_flags_.clear();
672 sram_map_.clear();
673 sram_variables_.clear();
679}
680
681absl::Status HackManifest::LoadFromFile(const std::string& filepath) {
682 std::ifstream file(filepath);
683 if (!file.is_open()) {
684 return absl::NotFoundError("Could not open manifest: " + filepath);
685 }
686 std::stringstream buffer;
687 buffer << file.rdbuf();
688 return LoadFromString(buffer.str());
689}
690
691absl::Status HackManifest::LoadFromString(const std::string& json_content) {
692 Reset();
693
694 Json root;
695 try {
696 root = Json::parse(json_content);
697 } catch (const std::exception& exc) {
698 return absl::InvalidArgumentError(
699 std::string("Failed to parse manifest JSON: ") + exc.what());
700 }
701
702 if (!root.is_object()) {
703 return absl::InvalidArgumentError("Hack manifest root must be an object");
704 }
705
706 ASSIGN_OR_RETURN(manifest_version_, ParseManifestVersion(root));
707 hack_name_ = root.value("hack_name", "");
708
709 std::vector<SnesAddressRange> editor_managed_regions;
711 ParseEditorManagedRegions(root, manifest_version_));
712
713 std::unordered_map<DungeonStreamType, DungeonStreamLayout>
714 dungeon_stream_layouts;
715 ASSIGN_OR_RETURN(dungeon_stream_layouts, ParseDungeonStreamLayouts(root));
716
717 ParsedProtectedRegions protected_regions;
719 ParseProtectedRegions(root, manifest_version_));
720
721 // Build pipeline
722 if (root.contains("build_pipeline")) {
723 auto& pipeline = root["build_pipeline"];
724 build_pipeline_.dev_rom = pipeline.value("dev_rom", "");
725 build_pipeline_.patched_rom = pipeline.value("patched_rom", "");
726 build_pipeline_.assembler = pipeline.value("assembler", "");
727 build_pipeline_.entry_point = pipeline.value("entry_point", "");
728 build_pipeline_.build_script = pipeline.value("build_script", "");
729 }
730
731 // Owned banks
732 if (root.contains("owned_banks") && root["owned_banks"].contains("banks")) {
733 for (auto& bank_json : root["owned_banks"]["banks"]) {
734 OwnedBank bank;
735 uint32_t bank_u32 = 0;
736 ASSIGN_OR_RETURN(bank_u32,
737 ParseHexAddress(bank_json.value("bank", "0x00")));
738 bank.bank = static_cast<uint8_t>(bank_u32 & 0xFF);
739 if (bank.bank >= 0x80) {
740 bank.bank &= 0x7F;
741 }
742 ASSIGN_OR_RETURN(bank.bank_start, ParseHexAddress(bank_json.value(
743 "bank_start", "0x000000")));
744 ASSIGN_OR_RETURN(bank.bank_end, ParseHexAddress(bank_json.value(
745 "bank_end", "0x000000")));
746 bank.bank_start = NormalizeSnesAddress(bank.bank_start);
747 bank.bank_end = NormalizeSnesAddress(bank.bank_end);
748 ASSIGN_OR_RETURN(bank.ownership, ParseOwnership(bank_json.value(
749 "ownership", "asm_owned")));
750 bank.ownership_note = bank_json.value("ownership_note", "");
751 owned_banks_[bank.bank] = bank;
752 }
753 }
754
755 // Room tags
756 if (root.contains("room_tags") && root["room_tags"].contains("tags")) {
757 for (auto& tag_json : root["room_tags"]["tags"]) {
758 RoomTagEntry tag;
759 uint32_t tag_id_u32 = 0;
760 ASSIGN_OR_RETURN(tag_id_u32,
761 ParseHexAddress(tag_json.value("tag_id", "0x00")));
762 tag.tag_id = static_cast<uint8_t>(tag_id_u32 & 0xFF);
764 ParseHexAddress(tag_json.value("address", "0x000000")));
765 tag.address = NormalizeSnesAddress(tag.address);
766 tag.name = tag_json.value("name", "");
767 tag.purpose = tag_json.value("purpose", "");
768 tag.source = tag_json.value("source", "");
769 tag.feature_flag = tag_json.value("feature_flag", "");
770 tag.enabled = tag_json.value("enabled", true);
771 room_tag_map_[tag.tag_id] = tag;
772 room_tags_.push_back(tag);
773 }
774 }
775
776 // Feature flags
777 if (root.contains("feature_flags") &&
778 root["feature_flags"].contains("flags")) {
779 for (auto& flag_json : root["feature_flags"]["flags"]) {
780 FeatureFlag flag;
781 flag.name = flag_json.value("name", "");
782 flag.value = flag_json.value("value", 0);
783 flag.enabled = flag_json.value("enabled", false);
784 flag.source = flag_json.value("source", "");
785 feature_flag_map_[flag.name] = flag;
786 feature_flags_.push_back(flag);
787 }
788 }
789
790 // SRAM variables
791 if (root.contains("sram") && root["sram"].contains("variables")) {
792 for (auto& var_json : root["sram"]["variables"]) {
793 SramVariable var;
794 var.name = var_json.value("name", "");
796 ParseHexAddress(var_json.value("address", "0x000000")));
797 var.purpose = var_json.value("purpose", "");
798 sram_map_[var.address] = var;
799 sram_variables_.push_back(var);
800 }
801 }
802
803 // Message layout
804 if (root.contains("messages")) {
805 auto& msg = root["messages"];
806 if (!msg.is_object()) {
807 return absl::InvalidArgumentError("messages must be an object");
808 }
809 if (msg.contains("hook_address") && msg["hook_address"].is_string()) {
811 ParseHexAddress(msg["hook_address"].get<std::string>()));
813 NormalizeSnesAddress(message_layout_.hook_address);
814 }
815 if (msg.contains("data_start")) {
817 ParseHexAddress(msg.value("data_start", "0x000000")));
819 NormalizeSnesAddress(message_layout_.data_start);
820 }
821 if (msg.contains("data_end")) {
823 ParseHexAddress(msg.value("data_end", "0x000000")));
824 message_layout_.data_end = NormalizeSnesAddress(message_layout_.data_end);
825 }
826 if (msg.contains("vanilla_count")) {
829 ParseBoundedNonnegativeInteger(
830 msg["vanilla_count"], "messages.vanilla_count",
831 static_cast<uint64_t>(std::numeric_limits<int>::max())));
832 } else {
834 }
835 if (msg.contains("expanded_range")) {
836 auto& expanded = msg["expanded_range"];
837 if (!expanded.is_object()) {
838 return absl::InvalidArgumentError(
839 "messages.expanded_range must be an object");
840 }
841 uint32_t first_id = 0;
842 uint32_t last_id = 0;
843 ASSIGN_OR_RETURN(first_id,
844 ParseHexAddress(expanded.value("first", "0x000")));
845 ASSIGN_OR_RETURN(last_id,
846 ParseHexAddress(expanded.value("last", "0x000")));
848 static_cast<uint16_t>(first_id & 0xFFFF);
850 static_cast<uint16_t>(last_id & 0xFFFF);
851 if (expanded.contains("count")) {
852 constexpr uint64_t kMaximumExpandedMessageCount =
853 static_cast<uint64_t>(std::numeric_limits<uint16_t>::max()) + 1;
855 ParseBoundedNonnegativeInteger(
856 expanded["count"], "messages.expanded_range.count",
857 kMaximumExpandedMessageCount));
858 } else {
860 }
861 }
862 if (msg.contains("source")) {
863 const auto& source = msg["source"];
864 if (!source.is_object()) {
865 return absl::InvalidArgumentError("messages.source must be an object");
866 }
867 constexpr std::array<absl::string_view, 4> kSourceKeys = {
868 "format", "version", "canonical_bundle_path",
869 "generated_asm_include_path"};
870 for (const auto& item : source.items()) {
871 if (std::find(kSourceKeys.begin(), kSourceKeys.end(), item.key()) ==
872 kSourceKeys.end()) {
873 return absl::InvalidArgumentError(absl::StrFormat(
874 "messages.source contains unknown field '%s'", item.key()));
875 }
876 }
877 for (absl::string_view key : kSourceKeys) {
878 if (!source.contains(key)) {
879 return absl::InvalidArgumentError(
880 absl::StrFormat("messages.source.%s is required", key));
881 }
882 }
883 if (!source["format"].is_string() ||
884 source["format"].get<std::string>() != "yaze-message-bundle") {
885 return absl::InvalidArgumentError(
886 "messages.source.format must be 'yaze-message-bundle'");
887 }
888 int source_version = 0;
889 auto source_version_or = ParseBoundedNonnegativeInteger(
890 source["version"], "messages.source.version",
891 static_cast<uint64_t>(std::numeric_limits<int>::max()));
892 if (!source_version_or.ok() || *source_version_or != 1) {
893 return absl::InvalidArgumentError(
894 "messages.source.version must be integer 1");
895 }
896 source_version = *source_version_or;
897 if (!source["canonical_bundle_path"].is_string() ||
898 source["canonical_bundle_path"].get<std::string>().empty()) {
899 return absl::InvalidArgumentError(
900 "messages.source.canonical_bundle_path must be a non-empty "
901 "string");
902 }
903 if (!source["generated_asm_include_path"].is_string() ||
904 source["generated_asm_include_path"].get<std::string>().empty()) {
905 return absl::InvalidArgumentError(
906 "messages.source.generated_asm_include_path must be a non-empty "
907 "string");
908 }
910 .format = source["format"].get<std::string>(),
911 .version = source_version,
912 .canonical_bundle_path =
913 source["canonical_bundle_path"].get<std::string>(),
914 .generated_asm_include_path =
915 source["generated_asm_include_path"].get<std::string>(),
916 };
917 }
918 }
919
920 total_hooks_ = protected_regions.total_hooks;
921 protected_regions_ = std::move(protected_regions.regions);
923 dungeon_stream_layouts_ = std::move(dungeon_stream_layouts);
924 loaded_ = true;
925 return absl::OkStatus();
926}
927
929 if (!loaded_)
931
932 address = NormalizeSnesAddress(address);
933
934 // Explicit hooks always win, including hooks that overlap an exact
935 // editor-managed exemption inside an ASM-owned bank.
936 if (IsProtected(address)) {
938 }
939
940 if (IsEditorManaged(address)) {
942 }
943
944 const uint8_t bank = static_cast<uint8_t>((address >> 16) & 0xFF);
945 const auto bank_it = owned_banks_.find(bank);
946 if (bank_it != owned_banks_.end()) {
947 return bank_it->second.ownership;
948 }
949
951}
952
953bool HackManifest::IsWriteOverwritten(uint32_t address) const {
954 const AddressOwnership ownership = ClassifyAddress(address);
955 return ownership == AddressOwnership::kHookPatched ||
956 ownership == AddressOwnership::kAsmOwned ||
958}
959
961 uint32_t address) const {
962 address = NormalizeSnesAddress(address);
963
964 // Start with the last possible region, then walk backwards so nested or
965 // overlapping protected regions cannot hide an earlier containing hook.
966 const auto upper = std::upper_bound(
967 protected_regions_.begin(), protected_regions_.end(), address,
968 [](uint32_t addr, const ProtectedRegion& region) {
969 return addr < region.start;
970 });
971
972 for (auto iter = std::make_reverse_iterator(upper);
973 iter != protected_regions_.rend(); ++iter) {
974 if (address >= iter->start && address < iter->end) {
975 return &*iter;
976 }
977 }
978 return nullptr;
979}
980
981bool HackManifest::IsProtected(uint32_t address) const {
982 return FindProtectedRegion(address) != nullptr;
983}
984
985bool HackManifest::IsEditorManaged(uint32_t address) const {
986 address = NormalizeSnesAddress(address);
987 const auto upper = std::upper_bound(
988 editor_managed_regions_.begin(), editor_managed_regions_.end(), address,
989 [](uint32_t addr, const SnesAddressRange& region) {
990 return addr < region.start;
991 });
992 if (upper == editor_managed_regions_.begin()) {
993 return false;
994 }
995 const SnesAddressRange& region = *std::prev(upper);
996 return region.start <= address && address < region.end;
997}
998
999std::optional<AddressOwnership> HackManifest::GetBankOwnership(
1000 uint8_t bank) const {
1001 if (bank >= 0x80) {
1002 bank &= 0x7F;
1003 }
1004 auto iter = owned_banks_.find(bank);
1005 if (iter == owned_banks_.end())
1006 return std::nullopt;
1007 return iter->second.ownership;
1008}
1009
1010std::string HackManifest::GetRoomTagLabel(uint8_t tag_id) const {
1011 auto iter = room_tag_map_.find(tag_id);
1012 if (iter == room_tag_map_.end())
1013 return "";
1014 return iter->second.name;
1015}
1016
1017std::optional<RoomTagEntry> HackManifest::GetRoomTag(uint8_t tag_id) const {
1018 auto iter = room_tag_map_.find(tag_id);
1019 if (iter == room_tag_map_.end())
1020 return std::nullopt;
1021 return iter->second;
1022}
1023
1024bool HackManifest::IsFeatureEnabled(const std::string& flag_name) const {
1025 auto iter = feature_flag_map_.find(flag_name);
1026 if (iter == feature_flag_map_.end())
1027 return false;
1028 return iter->second.enabled;
1029}
1030
1031std::vector<WriteConflict> HackManifest::AnalyzeWriteRanges(
1032 const std::vector<std::pair<uint32_t, uint32_t>>& ranges) const {
1033 std::vector<WriteConflict> conflicts;
1034 if (!loaded_) {
1035 return conflicts;
1036 }
1037
1038 for (const auto& range : ranges) {
1039 const uint32_t start = NormalizeSnesAddress(range.first);
1040 const uint32_t end = NormalizeSnesAddress(range.second);
1041 if (end <= start) {
1042 continue;
1043 }
1044
1045 // Classify one segment at a time. Every possible ownership transition is
1046 // a boundary: bank edges, exact editor-managed edges, and hook edges.
1047 // This prevents a partially exempt write from bypassing an owned neighbor.
1048 std::vector<uint32_t> boundaries = {start, end};
1049 const uint64_t first_bank_boundary =
1050 (static_cast<uint64_t>(start) & 0xFF0000u) + 0x010000u;
1051 for (uint64_t boundary = first_bank_boundary; boundary < end;
1052 boundary += 0x010000u) {
1053 boundaries.push_back(static_cast<uint32_t>(boundary));
1054 }
1055 auto add_range_boundaries = [start, end, &boundaries](const auto& regions) {
1056 for (const auto& region : regions) {
1057 if (region.start < end && start < region.end) {
1058 boundaries.push_back(std::max(start, region.start));
1059 boundaries.push_back(std::min(end, region.end));
1060 }
1061 }
1062 };
1063 add_range_boundaries(editor_managed_regions_);
1064 add_range_boundaries(protected_regions_);
1065
1066 std::sort(boundaries.begin(), boundaries.end());
1067 boundaries.erase(std::unique(boundaries.begin(), boundaries.end()),
1068 boundaries.end());
1069
1070 bool previous_was_conflict = false;
1072 std::string previous_module;
1073 for (size_t index = 0; index + 1 < boundaries.size(); ++index) {
1074 const uint32_t segment_start = boundaries[index];
1075 if (segment_start >= boundaries[index + 1]) {
1076 continue;
1077 }
1078
1079 const AddressOwnership ownership = ClassifyAddress(segment_start);
1080 if (!IsAsmOwned(ownership)) {
1081 previous_was_conflict = false;
1082 continue;
1083 }
1084
1085 std::string module;
1086 if (ownership == AddressOwnership::kHookPatched) {
1087 if (const ProtectedRegion* protected_region =
1088 FindProtectedRegion(segment_start);
1089 protected_region != nullptr) {
1090 module = protected_region->module;
1091 }
1092 } else {
1093 const uint8_t bank = static_cast<uint8_t>((segment_start >> 16) & 0xFF);
1094 if (const auto bank_it = owned_banks_.find(bank);
1095 bank_it != owned_banks_.end()) {
1096 module = bank_it->second.ownership_note;
1097 }
1098 }
1099
1100 if (!previous_was_conflict || ownership != previous_ownership ||
1101 module != previous_module) {
1102 conflicts.push_back({segment_start, ownership, module});
1103 }
1104 previous_was_conflict = true;
1105 previous_ownership = ownership;
1106 previous_module = std::move(module);
1107 }
1108 }
1109
1110 return conflicts;
1111}
1112
1113std::vector<WriteConflict> HackManifest::AnalyzePcWriteRanges(
1114 const std::vector<std::pair<uint32_t, uint32_t>>& pc_ranges) const {
1115 if (!loaded_) {
1116 return {};
1117 }
1118
1119 std::vector<std::pair<uint32_t, uint32_t>> snes_ranges;
1120 snes_ranges.reserve(pc_ranges.size());
1121
1122 for (const auto& range : pc_ranges) {
1123 uint32_t pc_start = range.first;
1124 const uint32_t pc_end = range.second;
1125 if (pc_end <= pc_start) {
1126 continue;
1127 }
1128
1129 // Split at LoROM bank boundaries (0x8000 bytes per bank segment in PC
1130 // space). PcToSnes() is linear within these segments.
1131 while (pc_start < pc_end) {
1132 const uint32_t next_boundary = (pc_start & ~0x7FFFu) + 0x8000u;
1133 const uint32_t seg_end = std::min(pc_end, next_boundary);
1134 const uint32_t seg_len = seg_end - pc_start;
1135 const uint32_t snes_start = PcToSnes(pc_start);
1136 const uint32_t snes_end = snes_start + seg_len;
1137 snes_ranges.emplace_back(snes_start, snes_end);
1138 pc_start = seg_end;
1139 }
1140 }
1141
1142 return AnalyzeWriteRanges(snes_ranges);
1143}
1144
1145std::string HackManifest::GetSramVariableName(uint32_t address) const {
1146 auto iter = sram_map_.find(address);
1147 if (iter == sram_map_.end())
1148 return "";
1149 return iter->second.name;
1150}
1151
1152bool HackManifest::IsExpandedMessage(uint16_t message_id) const {
1153 return message_id >= message_layout_.first_expanded_id &&
1154 message_id <= message_layout_.last_expanded_id;
1155}
1156
1158 DungeonStreamType stream) const {
1159 const auto iter = dungeon_stream_layouts_.find(stream);
1160 return iter == dungeon_stream_layouts_.end() ? nullptr : &iter->second;
1161}
1162
1163// ============================================================================
1164// Project Registry Loading
1165// ============================================================================
1166
1167absl::Status HackManifest::LoadProjectRegistry(const std::string& code_folder) {
1168 namespace fs = std::filesystem;
1169
1171 fs::path base(code_folder);
1172
1173 // Look for data files in Docs/Dev/Planning/ (generated output location)
1174 fs::path planning = base / "Docs" / "Dev" / "Planning";
1175
1176 // ── Load dungeons.json ──────────────────────────────────────────────────
1177 fs::path dungeons_path = planning / "dungeons.json";
1178 if (fs::exists(dungeons_path)) {
1179 std::ifstream file(dungeons_path);
1180 if (file.is_open()) {
1181 std::stringstream buffer;
1182 buffer << file.rdbuf();
1183 try {
1184 Json root = Json::parse(buffer.str());
1185 if (root.contains("dungeons") && root["dungeons"].is_array()) {
1186 for (const auto& dj : root["dungeons"]) {
1187 DungeonEntry entry;
1188 entry.id = dj.value("id", "");
1189 entry.name = dj.value("name", "");
1190 entry.vanilla_name = dj.value("vanilla_name", "");
1191
1192 // Rooms
1193 if (dj.contains("rooms") && dj["rooms"].is_array()) {
1194 for (const auto& rj : dj["rooms"]) {
1195 DungeonRoom room;
1196 std::string id_str = rj.value("id", "0x00");
1197 auto parsed = ParseHexAddress(id_str);
1198 room.id = parsed.ok() ? static_cast<int>(*parsed) : 0;
1199 room.name = rj.value("name", "");
1200 room.floor = rj.value("floor", "");
1201 room.has_grid_position =
1202 rj.contains("grid_row") && rj.contains("grid_col");
1203 room.grid_row = rj.value("grid_row", 0);
1204 room.grid_col = rj.value("grid_col", 0);
1205 room.type = rj.value("type", "normal");
1206 room.palette = rj.value("palette", 0);
1207 room.blockset = rj.value("blockset", 0);
1208 room.spriteset = rj.value("spriteset", 0);
1209 room.tag1 = static_cast<uint8_t>(rj.value("tag1", 0));
1210 room.tag2 = static_cast<uint8_t>(rj.value("tag2", 0));
1211 entry.rooms.push_back(std::move(room));
1212 }
1213 }
1214
1215 // Stairs
1216 if (dj.contains("stairs") && dj["stairs"].is_array()) {
1217 for (const auto& sj : dj["stairs"]) {
1218 DungeonConnection conn;
1219 std::string from_str = sj.value("from", "0x00");
1220 std::string to_str = sj.value("to", "0x00");
1221 auto from_parsed = ParseHexAddress(from_str);
1222 auto to_parsed = ParseHexAddress(to_str);
1223 conn.from_room =
1224 from_parsed.ok() ? static_cast<int>(*from_parsed) : 0;
1225 conn.to_room =
1226 to_parsed.ok() ? static_cast<int>(*to_parsed) : 0;
1227 conn.label = sj.value("label", "");
1228 entry.stairs.push_back(std::move(conn));
1229 }
1230 }
1231
1232 // Holewarps
1233 if (dj.contains("holewarps") && dj["holewarps"].is_array()) {
1234 for (const auto& hj : dj["holewarps"]) {
1235 DungeonConnection conn;
1236 std::string from_str = hj.value("from", "0x00");
1237 std::string to_str = hj.value("to", "0x00");
1238 auto from_parsed = ParseHexAddress(from_str);
1239 auto to_parsed = ParseHexAddress(to_str);
1240 conn.from_room =
1241 from_parsed.ok() ? static_cast<int>(*from_parsed) : 0;
1242 conn.to_room =
1243 to_parsed.ok() ? static_cast<int>(*to_parsed) : 0;
1244 conn.label = hj.value("label", "");
1245 entry.holewarps.push_back(std::move(conn));
1246 }
1247 }
1248
1249 // Doors
1250 if (dj.contains("doors") && dj["doors"].is_array()) {
1251 for (const auto& doorj : dj["doors"]) {
1252 DungeonConnection conn;
1253 std::string from_str = doorj.value("from", "0x00");
1254 std::string to_str = doorj.value("to", "0x00");
1255 auto from_parsed = ParseHexAddress(from_str);
1256 auto to_parsed = ParseHexAddress(to_str);
1257 conn.from_room =
1258 from_parsed.ok() ? static_cast<int>(*from_parsed) : 0;
1259 conn.to_room =
1260 to_parsed.ok() ? static_cast<int>(*to_parsed) : 0;
1261 conn.label = doorj.value("label", "");
1262 conn.direction = doorj.value("direction", "");
1263 entry.doors.push_back(std::move(conn));
1264 }
1265 }
1266
1267 project_registry_.dungeons.push_back(std::move(entry));
1268 }
1269 }
1270 } catch (const std::exception& exc) {
1271 LOG_WARN("HackManifest", "Failed to parse dungeons.json: %s",
1272 exc.what());
1273 }
1274 }
1275 }
1276
1277 // ── Load overworld.json ─────────────────────────────────────────────────
1278 fs::path overworld_path = planning / "overworld.json";
1279 if (fs::exists(overworld_path)) {
1280 std::ifstream file(overworld_path);
1281 if (file.is_open()) {
1282 std::stringstream buffer;
1283 buffer << file.rdbuf();
1284 try {
1285 Json root = Json::parse(buffer.str());
1286 if (root.contains("areas") && root["areas"].is_array()) {
1287 for (const auto& aj : root["areas"]) {
1288 OverworldArea area;
1289 std::string id_str = aj.value("area_id", "0x00");
1290 auto parsed = ParseHexAddress(id_str);
1291 area.area_id = parsed.ok() ? static_cast<int>(*parsed) : 0;
1292 area.name = aj.value("name", "");
1293 area.world = aj.value("world", "");
1294 area.grid_row = aj.value("grid_row", 0);
1295 area.grid_col = aj.value("grid_col", 0);
1296 project_registry_.overworld_areas.push_back(std::move(area));
1297 }
1298 }
1299 } catch (const std::exception& exc) {
1300 LOG_WARN("HackManifest", "Failed to parse overworld.json: %s",
1301 exc.what());
1302 }
1303 }
1304 }
1305
1306 // ── Load resource labels ────────────────────────────────────────────────
1307 // Priority 1: Unified oracle_resource_labels.json (all types)
1308 // Priority 2: Legacy oracle_room_labels.json (room type only)
1309 fs::path unified_path = planning / "oracle_resource_labels.json";
1310 fs::path legacy_path = planning / "oracle_room_labels.json";
1311
1312 auto normalize_label_id = [](const std::string& raw) -> std::string {
1313 // Project resource_labels keys are decimal strings (std::to_string(id)).
1314 // Support either decimal ("57") or explicit hex ("0x39") here.
1315 std::string s = raw;
1316 while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
1317 s.erase(s.begin());
1318 }
1319 while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
1320 s.pop_back();
1321 }
1322 if (s.empty()) {
1323 return raw;
1324 }
1325
1326 int base = 10;
1327 if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
1328 base = 16;
1329 s = s.substr(2);
1330 if (s.empty()) {
1331 return raw;
1332 }
1333 }
1334
1335 try {
1336 size_t idx = 0;
1337 const unsigned long value = std::stoul(s, &idx, base);
1338 if (idx != s.size()) {
1339 return raw;
1340 }
1341 if (value > static_cast<unsigned long>(std::numeric_limits<int>::max())) {
1342 return raw;
1343 }
1344 return std::to_string(static_cast<int>(value));
1345 } catch (...) {
1346 return raw;
1347 }
1348 };
1349
1350 if (fs::exists(unified_path)) {
1351 std::ifstream file(unified_path);
1352 if (file.is_open()) {
1353 std::stringstream buffer;
1354 buffer << file.rdbuf();
1355 try {
1356 Json root = Json::parse(buffer.str());
1357 // Parse all type sections (room, sprite, item, entrance, etc.)
1358 const std::vector<std::string> label_types = {
1359 "room", "sprite", "item", "entrance", "overworld_map", "music"};
1360 for (const auto& type_key : label_types) {
1361 if (root.contains(type_key) && root[type_key].is_object()) {
1362 for (const auto& [key, value] : root[type_key].items()) {
1363 if (value.is_string()) {
1364 const std::string normalized_key = normalize_label_id(key);
1366 .all_resource_labels[type_key][normalized_key] =
1367 value.get<std::string>();
1368 }
1369 }
1370 }
1371 }
1372 } catch (const std::exception& exc) {
1373 LOG_WARN("HackManifest",
1374 "Failed to parse oracle_resource_labels.json: %s", exc.what());
1375 }
1376 }
1377 } else if (fs::exists(legacy_path)) {
1378 // Backward compat: load room-only labels from legacy file
1379 std::ifstream file(legacy_path);
1380 if (file.is_open()) {
1381 std::stringstream buffer;
1382 buffer << file.rdbuf();
1383 try {
1384 Json root = Json::parse(buffer.str());
1385 if (root.contains("resource_labels") &&
1386 root["resource_labels"].contains("room")) {
1387 for (const auto& [key, value] :
1388 root["resource_labels"]["room"].items()) {
1389 const std::string normalized_key = normalize_label_id(key);
1390 project_registry_.all_resource_labels["room"][normalized_key] =
1391 value.get<std::string>();
1392 }
1393 }
1394 } catch (const std::exception& exc) {
1395 LOG_WARN("HackManifest", "Failed to parse oracle_room_labels.json: %s",
1396 exc.what());
1397 }
1398 }
1399 }
1400
1401 // `dungeons.json` is the canonical Oracle room/dungeon structure. Mirror its
1402 // room names into the generic resource-label registry so opening an Oracle
1403 // project can populate YazeProject::resource_labels and serialize those names
1404 // into the project file. This intentionally runs after oracle_*_labels.json
1405 // so stale generated label bundles cannot mask the current dungeon registry.
1406 size_t dungeon_room_labels = 0;
1407 for (const auto& dungeon : project_registry_.dungeons) {
1408 for (const auto& room : dungeon.rooms) {
1409 if (room.id >= 0 && !room.name.empty()) {
1410 project_registry_.all_resource_labels["room"][std::to_string(room.id)] =
1411 room.name;
1412 ++dungeon_room_labels;
1413 }
1414 }
1415 }
1416 if (dungeon_room_labels > 0) {
1417 LOG_DEBUG("HackManifest",
1418 "Mirrored %zu dungeon room names into project resource labels",
1419 dungeon_room_labels);
1420 }
1421
1422 // ── Load story events ──────────────────────────────────────────────────
1423 fs::path story_events_path = planning / "story_events.json";
1424 if (fs::exists(story_events_path)) {
1425 auto story_status =
1426 project_registry_.story_events.LoadFromJson(story_events_path.string());
1427 if (story_status.ok()) {
1429 if (oracle_progression_state_.has_value()) {
1431 } else {
1432 // Default to an initial "no progress" state so the graph renders with
1433 // sensible locked/available coloring even before live SRAM is wired up.
1434 project_registry_.story_events.UpdateStatus(/*crystal_bitfield=*/0,
1435 /*game_state=*/0);
1436 }
1437 LOG_DEBUG("HackManifest", "Loaded story events: %zu nodes, %zu edges",
1440 } else {
1441 LOG_WARN("HackManifest", "Failed to load story_events.json: %s",
1442 std::string(story_status.message()).c_str());
1443 }
1444 }
1445
1446 size_t total_labels = 0;
1447 for (const auto& [type, labels] : project_registry_.all_resource_labels) {
1448 total_labels += labels.size();
1449 }
1450
1451 if (!project_registry_.dungeons.empty() ||
1452 !project_registry_.overworld_areas.empty() || total_labels > 0) {
1453 LOG_DEBUG("HackManifest",
1454 "Loaded project registry: %zu dungeons, %zu overworld areas, "
1455 "%zu resource labels (%zu types)",
1457 project_registry_.overworld_areas.size(), total_labels,
1459 }
1460
1461 return absl::OkStatus();
1462}
1463
1471
1475 project_registry_.story_events.UpdateStatus(/*crystal_bitfield=*/0,
1476 /*game_state=*/0);
1477 }
1478}
1479
1480} // namespace yaze::core
bool is_object() const
Definition json.h:57
static Json parse(const std::string &)
Definition json.h:36
bool is_array() const
Definition json.h:58
bool is_string() const
Definition json.h:59
items_view items()
Definition json.h:88
T get() const
Definition json.h:49
bool empty() const
Definition json.h:62
bool contains(const std::string &) const
Definition json.h:53
T value(const std::string &, const T &def) const
Definition json.h:50
std::vector< WriteConflict > AnalyzeWriteRanges(const std::vector< std::pair< uint32_t, uint32_t > > &ranges) const
Analyze a set of address ranges for write conflicts.
std::unordered_map< std::string, FeatureFlag > feature_flag_map_
std::optional< RoomTagEntry > GetRoomTag(uint8_t tag_id) const
Get the full room tag entry for a tag ID.
std::unordered_map< uint8_t, OwnedBank > owned_banks_
AddressOwnership ClassifyAddress(uint32_t address) const
Classify a ROM address by ownership.
bool IsFeatureEnabled(const std::string &flag_name) const
std::vector< SramVariable > sram_variables_
std::vector< ProtectedRegion > protected_regions_
bool IsWriteOverwritten(uint32_t address) const
Check if a ROM write at this address would be overwritten by asar.
ProjectRegistry project_registry_
bool IsExpandedMessage(uint16_t message_id) const
bool IsEditorManaged(uint32_t address) const
Check if an address is explicitly managed by yaze.
BuildPipeline build_pipeline_
std::optional< AddressOwnership > GetBankOwnership(uint8_t bank) const
Get the bank ownership for a given bank number.
std::optional< OracleProgressionState > oracle_progression_state_
std::unordered_map< uint32_t, SramVariable > sram_map_
std::string GetSramVariableName(uint32_t address) const
std::unordered_map< uint8_t, RoomTagEntry > room_tag_map_
std::vector< SnesAddressRange > editor_managed_regions_
std::unordered_map< DungeonStreamType, DungeonStreamLayout > dungeon_stream_layouts_
const std::vector< ProtectedRegion > & protected_regions() const
absl::Status LoadProjectRegistry(const std::string &code_folder)
Load project registry data from the code folder.
const DungeonStreamLayout * GetDungeonStreamLayout(DungeonStreamType stream) const
Get an explicitly declared dungeon stream layout.
bool IsProtected(uint32_t address) const
Check if an address is in a protected region.
absl::Status LoadFromFile(const std::string &filepath)
Load manifest from a JSON file path.
absl::Status LoadFromString(const std::string &json_content)
Load manifest from a JSON string.
MessageLayout message_layout_
std::string GetRoomTagLabel(uint8_t tag_id) const
Get the human-readable label for a room tag ID.
std::vector< WriteConflict > AnalyzePcWriteRanges(const std::vector< std::pair< uint32_t, uint32_t > > &pc_ranges) const
Analyze a set of PC-offset ranges for write conflicts.
void SetOracleProgressionState(const OracleProgressionState &state)
const ProtectedRegion * FindProtectedRegion(uint32_t address) const
const std::vector< SnesAddressRange > & editor_managed_regions() const
std::vector< RoomTagEntry > room_tags_
std::vector< FeatureFlag > feature_flags_
const std::vector< StoryEventNode > & nodes() const
void AutoLayout()
Compute layout positions using topological sort + layered positioning.
void UpdateStatus(uint8_t crystal_bitfield, uint8_t game_state)
Update node completion status based on SRAM state.
bool loaded() const
Check if the graph has been loaded.
const std::vector< StoryEdge > & edges() const
absl::Status LoadFromJson(const std::string &path)
Load the graph from a JSON file.
#define LOG_DEBUG(category, format,...)
Definition log.h:103
#define LOG_WARN(category, format,...)
Definition log.h:107
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
absl::StatusOr< AddressOwnership > ParseOwnership(const std::string &str)
absl::StatusOr< ParsedProtectedRegions > ParseProtectedRegions(const Json &root, int manifest_version)
bool IsAsmOwned(AddressOwnership ownership)
absl::StatusOr< DungeonWriteStrategy > ParseWriteStrategy(const Json &object, const std::string &path)
absl::Status ValidateDisjointRanges(std::vector< NamedPcRange > ranges, const std::string &description)
absl::StatusOr< uint32_t > ParseProtectedAddress(const Json &object, const std::string &key, const std::string &path, bool require_mapped_lorom)
absl::StatusOr< uint32_t > ParseStrictHexValue(const Json &value, const std::string &field, uint32_t maximum)
absl::StatusOr< uint32_t > ParsePointerCount(const Json &object, const std::string &path)
absl::StatusOr< int > ParseBoundedNonnegativeInteger(const Json &value, absl::string_view field, uint64_t maximum)
const char * DungeonStreamTypeToString(DungeonStreamType stream)
absl::StatusOr< uint32_t > ParseLoRomAddress(const Json &object, const std::string &key, const std::string &path)
absl::StatusOr< int > ParseManifestVersion(const Json &root)
absl::StatusOr< std::unordered_map< DungeonStreamType, DungeonStreamLayout > > ParseDungeonStreamLayouts(const Json &root)
bool PcRangeContains(const SnesAddressRange &outer, const SnesAddressRange &inner)
absl::StatusOr< DungeonStreamLayout > ParseDungeonStreamLayout(DungeonStreamType stream, const Json &json)
absl::StatusOr< std::vector< SnesAddressRange > > ParseAddressRanges(const Json &object, const std::string &key, const std::string &path)
absl::StatusOr< std::vector< SnesAddressRange > > ParseEditorManagedRegions(const Json &root, int manifest_version)
absl::StatusOr< DungeonPointerEncoding > ParsePointerEncoding(const Json &object, const std::string &path)
std::string AddressOwnershipToString(AddressOwnership ownership)
DungeonStreamType
Dungeon stream kinds with allocator layouts in the hack manifest.
AddressOwnership
Ownership classification for ROM addresses and banks.
nlohmann::json json
uint32_t PcToSnes(uint32_t addr)
Definition snes.h:17
uint32_t SnesToPc(uint32_t addr) noexcept
Definition snes.h:8
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
Build pipeline information.
A connection between two rooms (stair, holewarp, or door).
A complete dungeon entry with rooms and connections.
std::vector< DungeonConnection > doors
std::vector< DungeonConnection > holewarps
std::vector< DungeonRoom > rooms
std::vector< DungeonConnection > stairs
A room within a dungeon, with spatial and metadata info.
Explicit pointer and storage layout for one dungeon stream.
DungeonWriteStrategy strategy
std::vector< SnesAddressRange > allocation_regions
std::optional< uint8_t > pointer_bank
std::vector< SnesAddressRange > data_regions
DungeonPointerEncoding pointer_encoding
A compile-time feature flag.
Message range information for the expanded message system.
std::optional< Source > source
Oracle of Secrets game progression state parsed from SRAM.
An overworld area from the overworld registry.
An expanded bank with ownership classification.
AddressOwnership ownership
std::string ownership_note
Project-level registry data loaded from the Oracle planning outputs.
std::vector< DungeonEntry > dungeons
std::vector< OverworldArea > overworld_areas
std::unordered_map< std::string, std::unordered_map< std::string, std::string > > all_resource_labels
A contiguous protected ROM region owned by the ASM hack.
A room tag entry from the dispatch table.
A half-open [start, end) range of canonical LoROM SNES addresses.
A custom SRAM variable definition.