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