yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
catalog_tile_transfer.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 <map>
10#include <memory>
11#include <set>
12#include <utility>
13
20#include "nlohmann/json.hpp"
21#include "util/indexed_png.h"
22#include "util/macro.h"
25
26namespace yaze::editor {
27namespace {
28
29constexpr size_t kPixels = 128 * 32;
30constexpr size_t kMaxCatalogBytes = 4 * 1024 * 1024;
31constexpr size_t kMaxSources = 4096;
32constexpr int kMaxAssetPages = 256;
33constexpr int kMaxAssetDimension = 2048;
34constexpr size_t kMaxAssetPixels = 1024 * 1024;
35
36absl::Status ValidatePixels(const std::vector<uint8_t>& pixels) {
37 if (pixels.size() != kPixels ||
38 std::any_of(pixels.begin(), pixels.end(),
39 [](uint8_t pixel) { return pixel > 7; })) {
40 return absl::InvalidArgumentError(
41 "Catalog transfer requires 4096 pixels with indices 0-7");
42 }
43 return absl::OkStatus();
44}
45
46absl::Status ValidateDestination(uint16_t sheet) {
47 if (sheet >= zelda3::kGfxSheetCount) {
48 return absl::InvalidArgumentError("Destination sheet is out of range");
49 }
52 return absl::InvalidArgumentError(
53 "Catalog transfer does not support 2bpp destination sheets");
54 }
55 return absl::OkStatus();
56}
57
58absl::StatusOr<std::string> ReadFile(const std::filesystem::path& path,
59 bool sheet = false,
60 size_t maximum = 64 * 1024 * 1024) {
61 std::error_code error;
62 const auto status = std::filesystem::status(path, error);
63 if (error || !std::filesystem::exists(status)) {
64 if (error == std::errc::permission_denied)
65 return absl::PermissionDeniedError("Cannot inspect catalog asset: " +
66 path.string());
67 return absl::NotFoundError("Catalog asset is missing: " + path.string());
68 }
69 if (!std::filesystem::is_regular_file(status))
70 return absl::FailedPreconditionError("Catalog asset is not a file: " +
71 path.string());
72 constexpr auto readable = std::filesystem::perms::owner_read |
73 std::filesystem::perms::group_read |
74 std::filesystem::perms::others_read;
75 if ((status.permissions() & readable) == std::filesystem::perms::none)
76 return absl::PermissionDeniedError("Catalog asset is unreadable: " +
77 path.string());
78 const auto size = std::filesystem::file_size(path, error);
79 if (error) {
80 return absl::PermissionDeniedError("Cannot inspect catalog asset size: " +
81 path.string());
82 }
83 if ((sheet && size != 1536) || size > maximum) {
84 return absl::InvalidArgumentError("Invalid catalog asset size: " +
85 path.string());
86 }
87 std::ifstream input(path, std::ios::binary);
88 if (!input)
89 return absl::PermissionDeniedError("Cannot open catalog asset: " +
90 path.string());
91 std::string bytes(static_cast<size_t>(size), '\0');
92 if ((!bytes.empty() && !input.read(bytes.data(), bytes.size())) ||
93 input.peek() != std::char_traits<char>::eof()) {
94 return absl::DataLossError("Cannot read complete catalog asset: " +
95 path.string());
96 }
97 return bytes;
98}
99
100absl::Status CheckHash(const std::string& bytes, std::string expected) {
101 if (expected.size() != 64 ||
102 !std::all_of(expected.begin(), expected.end(),
103 [](unsigned char c) { return std::isxdigit(c); })) {
104 return absl::InvalidArgumentError("Catalog SHA256 must have 64 hex digits");
105 }
106 std::transform(expected.begin(), expected.end(), expected.begin(),
107 [](unsigned char c) { return std::tolower(c); });
108 if (core::ComputeSourceArtifactSha256(bytes) != expected) {
109 return absl::DataLossError("Catalog asset SHA256 mismatch");
110 }
111 return absl::OkStatus();
112}
113
114size_t PixelOffset(int tile, int x, int y) {
115 return ((tile / 16) * 8 + y) * 128 + (tile % 16) * 8 + x;
116}
117
118using Json = nlohmann::json;
119
120absl::StatusOr<std::string> Text(const Json& value) {
121 if (!value.is_string() || value.get_ref<const std::string&>().empty() ||
122 value.get_ref<const std::string&>().size() > 4096 ||
123 value.get_ref<const std::string&>().find('\0') != std::string::npos)
124 return absl::InvalidArgumentError("Invalid catalog text field");
125 return value.get<std::string>();
126}
127
128absl::StatusOr<int> Integer(const Json& value, int minimum, int maximum) {
129 if (!value.is_number_integer() || value < minimum || value > maximum)
130 return absl::InvalidArgumentError("Catalog integer is outside its range");
131 return value.get<int>();
132}
133
134// Research catalogs carry optional notes of varying shape; only usable text
135// is retained.
136std::string OptionalText(const Json& reference, const char* key) {
137 if (!reference.contains(key))
138 return {};
139 auto text = Text(reference.at(key));
140 return text.ok() ? *std::move(text) : std::string();
141}
142
144 const std::string& category) {
145 static constexpr std::pair<const char*, CatalogResourceKind> kNames[] = {
146 {"overworld-tile", CatalogResourceKind::kOverworldTile},
147 {"dungeon-tile", CatalogResourceKind::kDungeonTile},
148 {"interior-tile", CatalogResourceKind::kInteriorTile},
149 {"sprite-pose", CatalogResourceKind::kSpritePose},
150 {"other-sheet", CatalogResourceKind::kOtherSheet}};
151 static constexpr std::pair<const char*, CatalogResourceKind> kCategories[] = {
156 const auto explicit_kind = OptionalText(reference, "resource_kind");
157 for (const auto& [name, kind] : kNames)
158 if (explicit_kind == name)
159 return kind;
160 for (const auto& [name, kind] : kCategories)
161 if (category == name)
162 return kind;
164}
165
166absl::Status CheckResourcesUnbound(const Json& reference) {
167 if (!reference.contains("resources_bound") ||
168 reference.at("resources_bound").is_null())
169 return absl::OkStatus();
170 if (!reference.at("resources_bound").is_boolean())
171 return absl::InvalidArgumentError(
172 "Catalog resources_bound must be a boolean");
173 if (reference.at("resources_bound").get<bool>())
174 return absl::FailedPreconditionError(
175 "Catalog art resources must remain unbound");
176 return absl::OkStatus();
177}
178
179void ReadResearchProvenance(const Json& reference, CatalogSheetSource& source) {
180 const std::pair<const char*, std::string*> fields[] = {
181 {"notes", &source.notes},
182 {"conversion", &source.conversion},
183 {"behavior_status", &source.behavior_status},
184 {"provenance_status", &source.provenance_status},
185 {"identification_confidence", &source.identification_confidence},
186 {"source_url", &source.source_url}};
187 for (const auto& [key, target] : fields)
188 *target = OptionalText(reference, key);
189 source.color_reduction = reference.contains("color_reduction") &&
190 reference.at("color_reduction").is_boolean() &&
191 reference.at("color_reduction").get<bool>();
192 source.resource_kind = ResolveResourceKind(reference, source.category);
193}
194
195absl::StatusOr<std::filesystem::path> ContainedPath(
196 const std::filesystem::path& root, const std::string& name) {
197 namespace fs = std::filesystem;
198 const fs::path configured(name);
199 if (name.empty() || name.find('\0') != std::string::npos ||
200 (!configured.is_absolute() && (name.find('\\') != std::string::npos ||
201 name.find(':') != std::string::npos)))
202 return absl::InvalidArgumentError("Invalid catalog payload path");
203 for (const auto& part : configured)
204 if (part == "..")
205 return absl::InvalidArgumentError(
206 "Catalog payload path contains traversal");
207 const auto candidate = (root / configured).lexically_normal();
208 const auto lexical_mismatch = std::mismatch(
209 root.begin(), root.end(), candidate.begin(), candidate.end());
210 if (lexical_mismatch.first != root.end())
211 return absl::InvalidArgumentError("Catalog payload escapes its asset root");
212 std::error_code error;
213 const auto path = fs::canonical(candidate, error);
214 if (error) {
215 if (error == std::errc::permission_denied)
216 return absl::PermissionDeniedError("Cannot resolve catalog payload: " +
217 name);
218 return absl::NotFoundError("Catalog payload is missing: " + name);
219 }
220 const auto mismatch =
221 std::mismatch(root.begin(), root.end(), path.begin(), path.end());
222 if (mismatch.first != root.end())
223 return absl::InvalidArgumentError("Catalog payload escapes its asset root");
224 return path;
225}
226
227absl::Status ValidatePage(const CatalogSheetSource& source) {
228 if ((source.bpp != 3 && source.bpp != 4) ||
229 source.indexed_pixels.size() != kPixels ||
230 std::any_of(source.indexed_pixels.begin(), source.indexed_pixels.end(),
231 [&](uint8_t pixel) { return pixel >= (1 << source.bpp); }))
232 return absl::InvalidArgumentError("Invalid indexed catalog page/depth");
233 if (!source.palette.empty() &&
234 (source.palette.size() != static_cast<size_t>(1 << source.bpp) ||
235 std::any_of(source.palette.begin(), source.palette.end(),
236 [](uint16_t color) { return color > 0x7fff; })))
237 return absl::InvalidArgumentError("Invalid catalog page palette");
238 return absl::OkStatus();
239}
240
241absl::Status ValidateAssembly(const CatalogSheetSource& source) {
242 if (source.page_count < 1 || source.page_count > kMaxAssetPages ||
243 source.pixel_width < 8 || source.pixel_width > kMaxAssetDimension ||
244 source.pixel_height < 8 || source.pixel_height > kMaxAssetDimension ||
245 source.pixel_width % 8 || source.pixel_height % 8 ||
246 static_cast<size_t>(source.pixel_width) * source.pixel_height >
248 return absl::InvalidArgumentError("Invalid catalog asset dimensions/pages");
249 if (source.assembly.empty()) {
250 if (source.page_count != 1 || source.pixel_width != 128 ||
251 source.pixel_height != 32)
252 return absl::InvalidArgumentError(
253 "Logical catalog asset requires assembly");
254 return absl::OkStatus();
255 }
256 if (source.assembly.size() > kMaxAssetPixels / 64)
257 return absl::InvalidArgumentError("Catalog assembly exceeds cell limit");
258 std::set<std::pair<int, int>> positions;
259 for (const auto& cell : source.assembly) {
260 if (cell.x < 0 || cell.x >= source.pixel_width / 8 || cell.y < 0 ||
261 cell.y >= source.pixel_height / 8 || cell.page < 0 ||
262 cell.page >= source.page_count || cell.page_tile < 0 ||
263 cell.page_tile >= 64 || !positions.insert({cell.x, cell.y}).second)
264 return absl::InvalidArgumentError(
265 "Invalid or duplicate catalog assembly cell");
266 }
267 return absl::OkStatus();
268}
269
270// The suffix never selects an encoding; the reference bpp does.
271absl::Status CheckPlanarPage(const Json& page,
272 const std::filesystem::path& root,
273 CatalogSheetSource& source) {
274 ASSIGN_OR_RETURN(auto name, Text(page.at("binary_path")));
275 if (!page.contains("binary_sha256"))
276 return absl::InvalidArgumentError(
277 "Catalog planar page requires binary_sha256");
278 ASSIGN_OR_RETURN(auto hash, Text(page.at("binary_sha256")));
279 ASSIGN_OR_RETURN(auto path, ContainedPath(root, name));
280 ASSIGN_OR_RETURN(auto bytes, ReadFile(path, false, kMaxCatalogBytes));
281 RETURN_IF_ERROR(CheckHash(bytes, hash));
282 const int bpp = source.bpp;
283 if (bytes.size() != static_cast<size_t>(64 * 8 * bpp))
284 return absl::InvalidArgumentError(
285 "Catalog planar page must hold 64 tiles at " + std::to_string(bpp) +
286 "bpp: " + path.string());
287 const std::vector<uint8_t> planar(bytes.begin(), bytes.end());
288 for (int tile = 0; tile < 64; ++tile) {
289 const auto decoded = gfx::UnpackBppTile(planar, tile * 8 * bpp, bpp);
290 for (int y = 0; y < 8; ++y)
291 for (int x = 0; x < 8; ++x)
292 if (decoded.data[y * 8 + x] !=
293 source.indexed_pixels[PixelOffset(tile, x, y)])
294 return absl::DataLossError(
295 "Catalog planar page differs from its indexed PNG at " +
296 std::to_string(bpp) + "bpp: " + path.string());
297 }
298 source.binary_path = path.string();
300 return absl::OkStatus();
301}
302
303absl::Status LoadIndexedReference(const Json& reference,
304 const std::filesystem::path& root,
305 const std::string& id,
306 const std::string& title,
307 std::vector<CatalogSheetSource>& sources) {
309 base.asset_id = id;
310 base.name = title;
311 base.source_kind = "indexed-png";
312 const std::pair<const char*, std::string*> fields[] = {
313 {"group", &base.group},
314 {"name", &base.name},
315 {"category", &base.category},
316 {"material", &base.material},
317 {"game", &base.game},
318 {"credit", &base.credit},
319 {"source_origin", &base.source_origin},
320 {"source_sha256", &base.source_sha256}};
321 for (const auto& [key, target] : fields)
322 if (reference.contains(key)) {
323 ASSIGN_OR_RETURN(*target, Text(reference.at(key)));
324 }
325 if (!base.source_sha256.empty()) {
326 // Validate provenance syntax without opening an external source hint.
327 if (base.source_sha256.size() != 64 ||
328 !std::all_of(base.source_sha256.begin(), base.source_sha256.end(),
329 [](unsigned char c) { return std::isxdigit(c); }))
330 return absl::InvalidArgumentError("Invalid catalog source SHA256");
331 }
332 if (reference.contains("bpp")) {
333 ASSIGN_OR_RETURN(base.bpp, Integer(reference.at("bpp"), 3, 4));
334 }
336 ReadResearchProvenance(reference, base);
337 if (reference.contains("physical_binding") &&
338 !reference.at("physical_binding").is_null() &&
339 reference.at("physical_binding") != "UNBOUND")
340 return absl::FailedPreconditionError(
341 "Catalog art resources must remain UNBOUND");
342 if (reference.contains("palette_bgr555")) {
343 const auto& palette = reference.at("palette_bgr555");
344 if (!palette.is_array() || palette.size() != size_t(1 << base.bpp))
345 return absl::InvalidArgumentError(
346 "Catalog palette does not match its depth");
347 for (const auto& color : palette) {
348 ASSIGN_OR_RETURN(int word, Integer(color, 0, 0x7fff));
349 base.palette.push_back(static_cast<uint16_t>(word));
350 }
351 }
352 Json pages;
353 if (reference.contains("pages")) {
354 pages = reference.at("pages");
355 if (!pages.is_array() || pages.empty() || pages.size() > kMaxAssetPages)
356 return absl::InvalidArgumentError("Catalog requires 1-256 indexed pages");
357 } else {
358 pages = Json::array({{{"path", reference.at("indexed_path")},
359 {"sha256", reference.at("indexed_sha256")}}});
360 }
361 base.page_count = static_cast<int>(pages.size());
362 if (sources.size() + pages.size() > kMaxSources)
363 return absl::ResourceExhaustedError("Catalog exceeds source page limit");
364 if (reference.contains("pixel_size")) {
365 const auto& size = reference.at("pixel_size");
366 if (!size.is_array() || size.size() != 2)
367 return absl::InvalidArgumentError(
368 "Catalog pixel_size requires width/height");
369 ASSIGN_OR_RETURN(base.pixel_width, Integer(size[0], 8, kMaxAssetDimension));
371 Integer(size[1], 8, kMaxAssetDimension));
372 }
373 if (reference.contains("assembly")) {
374 const auto& assembly = reference.at("assembly");
375 if (!assembly.is_array() || assembly.size() > kMaxAssetPixels / 64)
376 return absl::InvalidArgumentError("Invalid catalog assembly array");
377 for (const auto& item : assembly) {
379 ASSIGN_OR_RETURN(cell.x,
380 Integer(item.at("x"), 0, kMaxAssetDimension / 8));
381 ASSIGN_OR_RETURN(cell.y,
382 Integer(item.at("y"), 0, kMaxAssetDimension / 8));
384 Integer(item.at("page"), 0, kMaxAssetPages - 1));
385 ASSIGN_OR_RETURN(cell.page_tile, Integer(item.at("page_tile"), 0, 63));
386 for (const auto& [key, target] :
387 std::vector<std::pair<const char*, bool*>>{{"h", &cell.h},
388 {"v", &cell.v}}) {
389 if (item.contains(key)) {
390 if (!item.at(key).is_boolean())
391 return absl::InvalidArgumentError("Catalog flips must be booleans");
392 *target = item.at(key).get<bool>();
393 }
394 }
395 base.assembly.push_back(cell);
396 }
397 }
399 size_t retained_cells = base.assembly.size() * pages.size();
400 for (const auto& source : sources)
401 retained_cells += source.assembly.size();
402 if (retained_cells > 256 * 1024)
403 return absl::ResourceExhaustedError(
404 "Catalog exceeds retained assembly cell limit");
405 if (reference.contains("preview_path")) {
406 ASSIGN_OR_RETURN(auto name, Text(reference.at("preview_path")));
407 ASSIGN_OR_RETURN(auto path, ContainedPath(root, name));
408 ASSIGN_OR_RETURN(auto bytes, ReadFile(path, false, kMaxCatalogBytes));
409 if (reference.contains("preview_sha256")) {
410 ASSIGN_OR_RETURN(auto hash, Text(reference.at("preview_sha256")));
411 RETURN_IF_ERROR(CheckHash(bytes, hash));
412 }
413 ASSIGN_OR_RETURN(auto png, util::DecodePng(std::vector<uint8_t>(
414 bytes.begin(), bytes.end())));
415 if (static_cast<size_t>(png.width) * png.height > kMaxAssetPixels)
416 return absl::InvalidArgumentError(
417 "Catalog source preview exceeds pixel limit");
418 base.preview_path = path.string();
419 }
420 std::vector<CatalogSheetSource> loaded;
421 for (size_t page = 0; page < pages.size(); ++page) {
422 ASSIGN_OR_RETURN(auto name, Text(pages[page].at("path")));
423 ASSIGN_OR_RETURN(auto hash, Text(pages[page].at("sha256")));
424 ASSIGN_OR_RETURN(auto path, ContainedPath(root, name));
425 ASSIGN_OR_RETURN(auto bytes, ReadFile(path, false, kMaxCatalogBytes));
426 RETURN_IF_ERROR(CheckHash(bytes, hash));
427 ASSIGN_OR_RETURN(auto png, util::DecodePng(std::vector<uint8_t>(
428 bytes.begin(), bytes.end())));
429 if (!png.indexed || png.width != 128 || png.height != 32)
430 return absl::InvalidArgumentError(
431 "Catalog PNG page must be indexed 128x32");
432 if (std::any_of(png.indices.begin(), png.indices.end(),
433 [&](uint8_t index) { return index >= png.palette.size(); }))
434 return absl::InvalidArgumentError(
435 "Catalog PNG pixel references an undefined palette entry");
436 if (std::find(png.indices.begin(), png.indices.end(), 0) !=
437 png.indices.end() &&
438 png.palette[0][3] != 0)
439 return absl::InvalidArgumentError(
440 "Catalog PNG index0 must be transparent when used");
441 CatalogSheetSource source = base;
442 source.id =
443 base.page_count == 1 ? id : id + ".page-" + std::to_string(page);
444 source.title = base.page_count == 1
445 ? title
446 : title + " (page " + std::to_string(page + 1) + "/" +
447 std::to_string(base.page_count) + ")";
448 source.source_path = path.string();
450 source.page = static_cast<int>(page);
451 source.indexed_pixels = std::move(png.indices);
452 std::vector<uint16_t> png_palette(size_t(1 << source.bpp), 0);
453 for (size_t i = 0; i < png_palette.size() && i < png.palette.size(); ++i) {
454 const auto& color = png.palette[i];
455 if ((i != 0 && color[3] != 255) ||
456 (i == 0 && color[3] != 0 && color[3] != 255))
457 return absl::InvalidArgumentError(
458 "Catalog palette alpha must be binary index0 transparency");
459 png_palette[i] = uint16_t((color[0] >> 3) | ((color[1] >> 3) << 5) |
460 ((color[2] >> 3) << 10));
461 }
462 if (!base.palette.empty() && base.palette != png_palette)
463 return absl::InvalidArgumentError(
464 "Catalog PNG palette differs from declared BGR555 palette");
465 source.palette = std::move(png_palette);
466 if (source.source_sha256.empty())
467 source.source_sha256 = source.page_sha256;
469 if (pages[page].contains("binary_path"))
470 RETURN_IF_ERROR(CheckPlanarPage(pages[page], root, source));
471 if (!loaded.empty() && loaded.front().palette != source.palette)
472 return absl::InvalidArgumentError(
473 "Catalog assembly pages use different palettes");
474 loaded.push_back(std::move(source));
475 }
476 sources.insert(sources.end(), std::make_move_iterator(loaded.begin()),
477 std::make_move_iterator(loaded.end()));
478 return absl::OkStatus();
479}
480
481bool ContainsFolded(std::string value, std::string needle) {
482 const auto lower = [](unsigned char c) {
483 return static_cast<char>(std::tolower(c));
484 };
485 std::transform(value.begin(), value.end(), value.begin(), lower);
486 std::transform(needle.begin(), needle.end(), needle.begin(), lower);
487 return value.find(needle) != std::string::npos;
488}
489
490} // namespace
491
493 switch (kind) {
495 return "Overworld tile motif";
497 return "Dungeon tile motif";
499 return "Interior tile motif";
501 return "Sprite family poses";
503 break;
504 }
505 return "Other source sheet";
506}
507
509 const CatalogSourceFilter& filter) {
510 if (filter.kind && source.resource_kind != *filter.kind)
511 return false;
512 if (filter.palettes_only && source.palette.empty())
513 return false;
514 if (!filter.group.empty() && source.group != filter.group)
515 return false;
516 if (!filter.category.empty() && source.category != filter.category)
517 return false;
518 return ContainsFolded(source.title + " " + source.name + " " + source.id +
519 " " + source.material + " " + source.game + " " +
520 source.group + " " + source.category + " " +
521 source.credit + " " + source.notes,
522 filter.search);
523}
524
526 const CatalogSheetSource& source) {
527 if (source.bpp != 3)
528 return absl::FailedPreconditionError(
529 "This " + std::to_string(source.bpp) +
530 "bpp page cannot be written into a 3bpp ROM sheet; IDs and palette "
531 "rows do not convert implicitly");
532 return absl::OkStatus();
533}
534
535absl::StatusOr<std::vector<CatalogSheetSource>> LoadCatalogSheetSources(
536 const std::string& filename) {
537 if (filename.empty() || filename.find('\0') != std::string::npos)
538 return absl::InvalidArgumentError("Select a catalog file");
539 ASSIGN_OR_RETURN(auto bytes, ReadFile(filename, false, kMaxCatalogBytes));
540 try {
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");
548 }
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")) {
553 ASSIGN_OR_RETURN(auto name, Text(catalog.at("asset_root")));
554 ASSIGN_OR_RETURN(asset_root, ContainedPath(canonical_parent, name));
555 if (!std::filesystem::is_directory(asset_root))
556 return absl::InvalidArgumentError(
557 "Catalog asset_root is not a directory");
558 }
559 auto resolve = [&parent](const std::string& path) {
560 return (parent / std::filesystem::path(path)).lexically_normal();
561 };
562 std::vector<CatalogSheetSource> sources;
563 std::set<std::string> ids;
564 // Hash each distinct source only once, but check every reference's hash.
565 std::map<std::filesystem::path, std::string> source_hashes;
566 for (const auto& reference : catalog.at("references")) {
567 if (!reference.is_object()) {
568 continue;
569 }
570 if (reference.contains("role") &&
571 reference.at("role") != "source-sheet") {
572 continue;
573 }
574 const bool indexed =
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)
581 continue;
582 if (reference.contains("raw_path") && indexed)
583 return absl::InvalidArgumentError(
584 "Catalog reference has ambiguous payloads");
585 ASSIGN_OR_RETURN(auto id, Text(reference.at("id")));
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");
591 if (indexed) {
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));
598 continue;
599 }
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");
615 }
616 ASSIGN_OR_RETURN(auto raw, ReadFile(resolve(raw_name), /*sheet=*/true));
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)) {
621 ASSIGN_OR_RETURN(auto rom, ReadFile(source_path));
622 source_hashes.emplace(source_path,
624 }
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");
630 }
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());
636 }
637 const std::vector<uint8_t> native(raw.begin(), raw.end());
638 auto pixels = gfx::SnesTo8bppSheet(native, /*bpp=*/3);
639 RETURN_IF_ERROR(ValidatePixels(pixels));
640 CatalogSheetSource source{id, title, source_path.string(),
641 std::move(pixels)};
642 source.asset_id = id;
643 source.name = title;
644 source.source_kind = "snes-3bpp";
645 source.source_sha256 = expected;
646 source.page_sha256 = core::ComputeSourceArtifactSha256(raw);
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)) {
657 ASSIGN_OR_RETURN(*target, Text(reference.at(key)));
658 }
659 RETURN_IF_ERROR(CheckResourcesUnbound(reference));
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) {
667 ASSIGN_OR_RETURN(int word, Integer(color, 0, 0x7fff));
668 source.palette.push_back(static_cast<uint16_t>(word));
669 }
670 }
671 sources.push_back(std::move(source));
672 if (sources.size() > kMaxSources)
673 return absl::ResourceExhaustedError(
674 "Catalog exceeds source page limit");
675 }
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");
680 return sources;
681 } catch (const nlohmann::json::exception& error) {
682 return absl::InvalidArgumentError(std::string("Malformed catalog sheet: ") +
683 error.what());
684 } catch (const std::filesystem::filesystem_error& error) {
685 return absl::InvalidArgumentError(error.what());
686 }
687}
688
689absl::StatusOr<CatalogAssetPreview> AssembleCatalogAsset(
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();
701 RETURN_IF_ERROR(ValidateAssembly(first));
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) {
706 RETURN_IF_ERROR(ValidatePage(*source));
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;
716 }
717 CatalogAssetPreview preview;
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()) {
723 preview.indexed_pixels = first.indexed_pixels;
724 return preview;
725 }
726 preview.indexed_pixels.assign(
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)
732 preview
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)];
736 }
737 return preview;
738}
739
740absl::StatusOr<CatalogTileTransferPlan> PreviewCatalogTileTransfer(
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) {
745 RETURN_IF_ERROR(ValidateDestination(destination_sheet));
746 RETURN_IF_ERROR(ValidatePixels(destination_pixels));
747 RETURN_IF_ERROR(ValidatePixels(source_pixels));
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");
752 }
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);
761 }
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");
766 }
767 std::array<bool, 64> seen{};
769 destination_sheet, destination_pixels, destination_pixels, {}};
770 int destination = first_destination_tile;
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");
775 }
776 seen[source] = true;
777 if (preserve_layout) {
778 destination = first_destination_tile + (source / 16 - min_y) * 16 +
779 source % 16 - min_x;
780 }
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;
788 }
789 }
790 if (changed)
791 plan.changed_tiles.push_back(destination);
792 ++destination;
793 }
794 return plan;
795}
796
798 GraphicsEditorState& state,
799 UndoManager* undo_manager) {
800 if (undo_manager == nullptr) {
801 return absl::FailedPreconditionError(
802 "Catalog transfer requires undo history");
803 }
804 if (!plan.destination_owner || plan.destination_owner != state.game_data ||
805 state.current_sheet_id != plan.sheet_id || state.selection.is_floating) {
806 return absl::FailedPreconditionError(
807 "Catalog destination owner/sheet changed or selection is floating");
808 }
809 RETURN_IF_ERROR(ValidateDestination(plan.sheet_id));
810 RETURN_IF_ERROR(ValidatePixels(plan.before_pixels));
811 RETURN_IF_ERROR(ValidatePixels(plan.after_pixels));
812 const auto* store = ActiveSheetStore(state);
813 if (!store || !store->HasSheet(plan.sheet_id)) {
814 return absl::FailedPreconditionError(
815 "Catalog destination session is inactive");
816 }
817 if (plan.destination_revision == 0 ||
818 plan.destination_revision != store->Revision(plan.sheet_id)) {
819 return absl::FailedPreconditionError(
820 "Catalog destination revision changed; preview again");
821 }
822 const auto current = store->Sheet(plan.sheet_id);
823 if (!std::equal(current.begin(), current.end(), plan.before_pixels.begin())) {
824 return absl::FailedPreconditionError(
825 "Catalog destination changed after preview; preview again");
826 }
827 auto diff = MakeSheetPixelDiff(plan.sheet_id, current, plan.after_pixels);
828 if (diff.empty())
829 return absl::OkStatus();
830 auto action = std::make_unique<GraphicsPixelEditAction>(
831 &state, std::move(diff), "Copy catalog tiles");
833 undo_manager->Push(std::move(action));
834 return absl::OkStatus();
835}
836
837} // namespace yaze::editor
static Json array()
Definition json.h:35
Shared state between GraphicsEditor panel components.
Manages undo/redo stacks for a single editor.
void Push(std::unique_ptr< UndoAction > action)
Locates graphics sheets in the ROM and writes edited sheets back.
One ROM session's graphics sheet pixels, with a revision per sheet.
The Graphics editor's path for sheet pixel writes.
struct destination destination
Room transition destination.
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
std::string ComputeSourceArtifactSha256(std::string_view content)
absl::StatusOr< std::string > ReadFile(const std::filesystem::path &path, bool sheet=false, size_t maximum=64 *1024 *1024)
absl::Status CheckPlanarPage(const Json &page, const std::filesystem::path &root, CatalogSheetSource &source)
absl::Status ValidatePixels(const std::vector< uint8_t > &pixels)
absl::Status ValidateAssembly(const CatalogSheetSource &source)
absl::Status LoadIndexedReference(const Json &reference, const std::filesystem::path &root, const std::string &id, const std::string &title, std::vector< CatalogSheetSource > &sources)
absl::Status ValidatePage(const CatalogSheetSource &source)
absl::StatusOr< std::filesystem::path > ContainedPath(const std::filesystem::path &root, const std::string &name)
std::string OptionalText(const Json &reference, const char *key)
absl::Status CheckHash(const std::string &bytes, std::string expected)
bool ContainsFolded(std::string value, std::string needle)
CatalogResourceKind ResolveResourceKind(const Json &reference, const std::string &category)
void ReadResearchProvenance(const Json &reference, CatalogSheetSource &source)
Editors are the view controllers for the application.
zelda3::GraphicsSheetStore * ActiveSheetStore(const GraphicsEditorState &state)
const char * CatalogResourceKindName(CatalogResourceKind kind)
absl::StatusOr< std::vector< CatalogSheetSource > > LoadCatalogSheetSources(const std::string &filename)
absl::Status ApplyCatalogTileTransfer(const CatalogTileTransferPlan &plan, GraphicsEditorState &state, UndoManager *undo_manager)
absl::Status CommitSheetPixels(GraphicsEditorState &state, uint16_t sheet, absl::Span< const uint8_t > pixels)
absl::StatusOr< CatalogAssetPreview > AssembleCatalogAsset(const std::vector< CatalogSheetSource > &sources, const std::string &asset_id)
absl::Status ValidateCatalogSourceForRomSheet(const CatalogSheetSource &source)
bool CatalogSourceMatches(const CatalogSheetSource &source, const CatalogSourceFilter &filter)
absl::StatusOr< CatalogTileTransferPlan > PreviewCatalogTileTransfer(uint16_t destination_sheet, const std::vector< uint8_t > &destination_pixels, const std::vector< uint8_t > &source_pixels, const std::vector< int > &source_tiles, int first_destination_tile, bool preserve_layout)
SheetPixelDiff MakeSheetPixelDiff(uint16_t sheet, absl::Span< const uint8_t > before, absl::Span< const uint8_t > after)
The pixels that differ between two 4096-byte sheets.
snes_tile8 UnpackBppTile(std::span< const uint8_t > data, const uint32_t offset, const uint32_t bpp)
Definition snes_tile.cc:25
std::vector< uint8_t > SnesTo8bppSheet(std::span< const uint8_t > sheet, int bpp, int num_sheets)
Definition snes_tile.cc:133
absl::StatusOr< PngImage > DecodePng(const std::vector< uint8_t > &bytes)
GfxSheetStorageKind GetGfxSheetStorageKind(uint16_t sheet_id)
constexpr size_t kGfxSheetCount
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
Room transition destination.
Definition zelda.h:448
std::vector< CatalogAssemblyCell > assembly
std::optional< CatalogResourceKind > kind