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