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