536 const std::string& filename) {
537 if (filename.empty() || filename.find(
'\0') != std::string::npos)
538 return absl::InvalidArgumentError(
"Select a catalog file");
541 const auto catalog = nlohmann::json::parse(bytes);
542 if (!catalog.is_object() || !catalog.contains(
"schema_version") ||
543 !catalog.at(
"schema_version").is_number_integer() ||
544 catalog.at(
"schema_version") != 1 || !catalog.contains(
"references") ||
545 !catalog.at(
"references").is_array() ||
546 catalog.at(
"references").size() > kMaxSources) {
547 return absl::InvalidArgumentError(
"Expected schema_version 1 references");
549 const auto parent = std::filesystem::absolute(filename).parent_path();
550 const auto canonical_parent = std::filesystem::canonical(parent);
551 auto asset_root = canonical_parent;
552 if (catalog.contains(
"asset_root")) {
555 if (!std::filesystem::is_directory(asset_root))
556 return absl::InvalidArgumentError(
557 "Catalog asset_root is not a directory");
559 auto resolve = [&parent](
const std::string& path) {
560 return (parent / std::filesystem::path(path)).lexically_normal();
562 std::vector<CatalogSheetSource> sources;
563 std::set<std::string> ids;
565 std::map<std::filesystem::path, std::string> source_hashes;
566 for (
const auto& reference : catalog.at(
"references")) {
567 if (!reference.is_object()) {
570 if (reference.contains(
"role") &&
571 reference.at(
"role") !=
"source-sheet") {
575 reference.contains(
"pages") || reference.contains(
"indexed_path");
576 if (reference.contains(
"source_kind") &&
577 reference.at(
"source_kind") ==
"indexed-png" && !indexed)
578 return absl::InvalidArgumentError(
579 "Indexed catalog reference has no pages");
580 if (!reference.contains(
"raw_path") && !indexed)
582 if (reference.contains(
"raw_path") && indexed)
583 return absl::InvalidArgumentError(
584 "Catalog reference has ambiguous payloads");
587 auto title, Text(reference.contains(
"title") ? reference.at(
"title")
588 : reference.at(
"name")));
589 if (!ids.insert(
id).second)
590 return absl::InvalidArgumentError(
"Duplicate catalog asset ID");
592 if (!reference.contains(
"source_kind") ||
593 reference.at(
"source_kind") !=
"indexed-png")
594 return absl::InvalidArgumentError(
595 "Indexed catalog requires source_kind indexed-png");
597 LoadIndexedReference(reference, asset_root,
id, title, sources));
600 if ((reference.contains(
"bpp") && reference.at(
"bpp") != 3) ||
601 (reference.contains(
"source_kind") &&
602 reference.at(
"source_kind") !=
"snes-3bpp"))
603 return absl::InvalidArgumentError(
604 "Raw catalog sheets require SNES 3bpp");
605 const auto raw_name = reference.at(
"raw_path").get<std::string>();
606 const auto source_name =
607 reference.contains(
"source_rom")
608 ? reference.at(
"source_rom").get<std::string>()
609 : reference.at(
"local_hint").get<std::string>();
610 if (raw_name.empty() || source_name.empty() ||
611 raw_name.find(
'\0') != std::string::npos ||
612 source_name.find(
'\0') != std::string::npos) {
613 return absl::InvalidArgumentError(
614 "Malformed catalog sheet identity/path");
618 CheckHash(raw, reference.at(
"decoded_sha256").get<std::string>()));
619 const auto source_path = resolve(source_name);
620 if (!source_hashes.contains(source_path)) {
622 source_hashes.emplace(source_path,
625 auto expected = reference.at(
"source_sha256").get<std::string>();
626 if (expected.size() != 64 ||
627 !std::all_of(expected.begin(), expected.end(),
628 [](
unsigned char c) { return std::isxdigit(c); })) {
629 return absl::InvalidArgumentError(
"Invalid catalog source SHA256");
631 std::transform(expected.begin(), expected.end(), expected.begin(),
632 [](
unsigned char c) { return std::tolower(c); });
633 if (source_hashes.at(source_path) != expected) {
634 return absl::DataLossError(
"Catalog source ROM SHA256 mismatch: " +
635 source_path.string());
637 const std::vector<uint8_t> native(raw.begin(), raw.end());
644 source.source_kind =
"snes-3bpp";
645 source.source_sha256 = expected;
647 const std::pair<const char*, std::string*> metadata[] = {
648 {
"group", &source.group},
649 {
"name", &source.name},
650 {
"category", &source.category},
651 {
"material", &source.material},
652 {
"game", &source.game},
653 {
"credit", &source.credit},
654 {
"source_origin", &source.source_origin}};
655 for (
const auto& [key, target] : metadata)
656 if (reference.contains(key)) {
660 ReadResearchProvenance(reference, source);
661 if (reference.contains(
"palette_bgr555")) {
662 const auto& palette = reference.at(
"palette_bgr555");
663 if (!palette.is_array() || palette.size() != 8)
664 return absl::InvalidArgumentError(
665 "Raw catalog palette requires eight BGR555 colors");
666 for (
const auto& color : palette) {
668 source.palette.push_back(
static_cast<uint16_t
>(word));
671 sources.push_back(std::move(source));
672 if (sources.size() > kMaxSources)
673 return absl::ResourceExhaustedError(
674 "Catalog exceeds source page limit");
676 std::set<std::string> page_ids;
677 for (
const auto& source : sources)
678 if (!page_ids.insert(source.id).second)
679 return absl::InvalidArgumentError(
"Duplicate catalog source page ID");
681 }
catch (
const nlohmann::json::exception& error) {
682 return absl::InvalidArgumentError(std::string(
"Malformed catalog sheet: ") +
684 }
catch (
const std::filesystem::filesystem_error& error) {
685 return absl::InvalidArgumentError(error.what());
690 const std::vector<CatalogSheetSource>& sources,
691 const std::string& asset_id) {
692 if (asset_id.empty())
693 return absl::InvalidArgumentError(
"Select a catalog asset ID");
694 std::vector<const CatalogSheetSource*> selected;
695 for (
const auto& source : sources)
696 if ((source.asset_id.empty() ? source.id : source.asset_id) == asset_id)
697 selected.push_back(&source);
698 if (selected.empty())
699 return absl::NotFoundError(
"Catalog asset was not found");
700 const auto& first = *selected.front();
702 if (selected.size() !=
static_cast<size_t>(first.page_count))
703 return absl::InvalidArgumentError(
"Catalog assembly is missing a page");
704 std::vector<const CatalogSheetSource*> pages(first.page_count,
nullptr);
705 for (
const auto* source : selected) {
707 if (source->page < 0 || source->page >= first.page_count ||
708 pages[source->page] || source->page_count != first.page_count ||
709 source->bpp != first.bpp || source->pixel_width != first.pixel_width ||
710 source->pixel_height != first.pixel_height ||
711 source->assembly != first.assembly ||
712 source->palette != first.palette || source->resources_bound)
713 return absl::InvalidArgumentError(
714 "Catalog assembly page metadata differs");
715 pages[source->page] = source;
718 preview.
width = first.pixel_width;
719 preview.
height = first.pixel_height;
720 preview.
bpp = first.bpp;
721 preview.
palette = first.palette;
722 if (first.assembly.empty()) {
727 static_cast<size_t>(preview.
width) * preview.
height, 0);
728 for (
const auto& cell : first.assembly) {
729 const auto& pixels = pages[cell.page]->indexed_pixels;
730 for (
int y = 0; y < 8; ++y)
731 for (
int x = 0; x < 8; ++x)
733 .indexed_pixels[(cell.y * 8 + y) * preview.
width + cell.x * 8 + x] =
734 pixels[PixelOffset(cell.page_tile, cell.h ? 7 - x : x,
735 cell.v ? 7 - y : y)];
741 uint16_t destination_sheet,
const std::vector<uint8_t>& destination_pixels,
742 const std::vector<uint8_t>& source_pixels,
743 const std::vector<int>& source_tiles,
int first_destination_tile,
744 bool preserve_layout) {
748 if (source_tiles.empty() || first_destination_tile < 0 ||
749 first_destination_tile >= 64 ||
750 source_tiles.size() >
static_cast<size_t>(64 - first_destination_tile)) {
751 return absl::InvalidArgumentError(
"Catalog tile selection exceeds sheet");
753 int min_x = 15, min_y = 3, max_x = 0, max_y = 0;
754 for (
int source : source_tiles) {
755 if (source < 0 || source >= 64)
756 return absl::InvalidArgumentError(
"Catalog source tile is out of range");
757 min_x = std::min(min_x, source % 16);
758 max_x = std::max(max_x, source % 16);
759 min_y = std::min(min_y, source / 16);
760 max_y = std::max(max_y, source / 16);
762 if (preserve_layout && (first_destination_tile % 16 + max_x - min_x >= 16 ||
763 first_destination_tile / 16 + max_y - min_y >= 4)) {
764 return absl::InvalidArgumentError(
765 "Selected tile layout exceeds destination sheet");
767 std::array<bool, 64> seen{};
769 destination_sheet, destination_pixels, destination_pixels, {}};
771 for (
int source : source_tiles) {
772 if (source < 0 || source >= 64 || seen[source]) {
773 return absl::InvalidArgumentError(
774 "Invalid or duplicate catalog source tile");
777 if (preserve_layout) {
778 destination = first_destination_tile + (source / 16 - min_y) * 16 +
781 bool changed =
false;
782 for (
int y = 0; y < 8; ++y) {
783 for (
int x = 0; x < 8; ++x) {
784 const auto value = source_pixels[PixelOffset(source, x, y)];
785 const auto offset = PixelOffset(
destination, x, y);
786 changed |= plan.after_pixels[offset] != value;
787 plan.after_pixels[offset] = value;