197 const std::filesystem::path& logical_pack) {
198 auto bytes = ReadFile(logical_pack);
200 return bytes.status();
202 const auto document = ParseJson(*bytes);
203 auto catalog_path = RelativePath(logical_pack.parent_path(),
204 document.at(
"catalog").get<std::string>());
205 if (!catalog_path.ok())
206 return catalog_path.status();
207 auto catalog_bytes = ReadFile(*catalog_path);
208 if (!catalog_bytes.ok())
209 return catalog_bytes.status();
210 const auto catalog = ParseJson(*catalog_bytes);
211 if (Integer(catalog.at(
"schema_version"), 2) != 2 ||
212 !catalog.at(
"resources").is_object())
213 return Invalid(
"Expected V2 import catalog");
214 std::map<std::string, std::vector<uint8_t>> resources;
215 for (
const auto& [
id, resource] : catalog.at(
"resources").items()) {
216 if (
id.empty() || resource.at(
"format") !=
"snes4bpp") {
217 return Invalid(
"Import resource must be named native 4bpp");
219 const int count = Integer(resource.at(
"byte_count"), 0x8000);
220 if (!count || count % 32)
221 return Invalid(
"Unaligned import resource size");
222 auto path = RelativePath(catalog_path->parent_path(),
223 resource.at(
"path").get<std::string>());
225 return path.status();
226 auto body = ReadFile(*path, 0x8000);
228 return body.status();
229 if (body->size() !=
static_cast<size_t>(count) ||
231 resource.at(
"sha256").get<std::string>()) {
232 return absl::DataLossError(
"Import resource size or SHA256 mismatch");
234 resources.emplace(
id, std::vector<uint8_t>(body->begin(), body->end()));
236 auto validated_catalog =
238 if (!validated_catalog.ok())
239 return validated_catalog.status();
242 const auto& dimensions = document.at(
"tile16_grid_size");
243 if (!dimensions.is_array() || dimensions.size() != 2)
244 return Invalid(
"Invalid import grid");
245 asset.
width = Integer(dimensions[0], 256);
246 asset.
height = Integer(dimensions[1], 256);
248 return Invalid(
"Empty import grid");
249 if (document.contains(
"source_dimensions")) {
250 const auto& pixels = document.at(
"source_dimensions");
251 if (!pixels.is_array() || pixels.size() != 2 ||
252 Integer(pixels[0], 4096) != asset.
width * 16 ||
253 Integer(pixels[1], 4096) != asset.
height * 16) {
255 "Source pixel dimensions differ from the logical Tile16 grid");
259 std::set<std::string> resource_groups;
260 if (!document.at(
"resources").is_array() ||
261 !document.at(
"palette_groups").is_array() ||
262 !document.at(
"tile16_cells").is_array())
263 return Invalid(
"Logical import tables must be arrays");
264 for (
const auto& item : document.at(
"resources")) {
265 if (item.contains(
"physical_destinations") &&
266 !item.at(
"physical_destinations").is_null())
268 "Logical source cannot grant physical character destinations");
269 const auto id = item.at(
"logical_palette").get<std::string>();
271 resources.find(item.at(
"resource_id").get<std::string>());
272 if (!resource_groups.insert(
id).second || source == resources.end()) {
273 return Invalid(
"Duplicate or unknown import palette resource");
278 if (item.contains(
"bytes") &&
279 Integer(item.at(
"bytes"), 0x8000) != group.
characters.size())
280 return Invalid(
"Logical import byte count differs from resource");
281 if (item.contains(
"characters") && Integer(item.at(
"characters"), 1024) !=
283 return Invalid(
"Logical import character count differs from resource");
285 const Json* palette =
nullptr;
286 for (
const auto& entry : document.at(
"palette_groups")) {
287 if (entry.at(
"logical_palette") == id) {
289 return Invalid(
"Duplicate logical import palette");
294 return Invalid(
"Missing logical import palette colors");
295 if (palette->
contains(
"physical_BG_row") &&
296 !palette->at(
"physical_BG_row").
is_null())
298 "Logical source cannot grant a physical palette destination");
299 const auto& words = palette->at(
"BGR555_words");
300 if (!words.is_array() || words.size() != 16)
301 return Invalid(
"Import palette needs16 entries");
303 ? &palette->at(
"RGB_entries")
306 return Invalid(
"Invalid import RGB entries");
307 for (
int i = 0; i < 16; ++i) {
308 if (words[i].is_null()) {
309 if (rgb && !(*rgb)[i].is_null())
310 return Invalid(
"RGB entry has no BGR555 source");
313 const int word = Integer(words[i], 0x7FFF);
314 if (rgb && (*rgb)[i].is_null())
316 if (i == 0 || i == 8) {
317 if (rgb || word != 0)
318 return Invalid(
"Import indices0/8 must remain transparent");
322 if (!(*rgb)[i].is_array() || (*rgb)[i].size() != 3)
323 return Invalid(
"Invalid import RGB color");
324 for (
int c = 0; c < 3; ++c) {
325 const int component = (word >> (5 * c)) & 31;
326 if (Integer((*rgb)[i][c], 255) !=
327 ((component << 3) | (component >> 2))) {
328 return Invalid(
"Import RGB color does not exactly match BGR555");
332 group.
colors[i] =
static_cast<uint16_t
>(word);
334 asset.
palettes.push_back(std::move(group));
336 if (document.at(
"palette_groups").size() != asset.
palettes.size())
337 return Invalid(
"Unmatched logical palettes");
338 std::set<std::pair<int, int>> positions;
339 for (
const auto& item : document.at(
"tile16_cells")) {
340 if (item.contains(
"physical_Tile16_id") &&
341 !item.at(
"physical_Tile16_id").is_null())
343 "Logical source cannot grant a physical Tile16 destination");
344 const int x = Integer(item.at(
"x"), asset.
width - 1);
345 const int y = Integer(item.at(
"y"), asset.
height - 1);
346 if (!positions.emplace(x, y).second || item.at(
"quadrants").size() != 4) {
347 return Invalid(
"Duplicate import cell or invalid quadrants");
349 for (
int i = 0; i < 4; ++i) {
350 const auto& part = item.at(
"quadrants")[i];
351 auto& q = asset.
cells[y * asset.
width + x].quadrants[i];
352 if (part.value(
"transparent",
false)) {
353 if (part.contains(
"logical_palette") ||
354 part.contains(
"logical_character"))
356 "Transparent import quadrant has conflicting art references");
359 q.transparent =
false;
360 q.palette = part.at(
"logical_palette").get<std::string>();
361 q.character = Integer(part.at(
"logical_character"), 1023);
362 q.hflip = part.at(
"hflip").get<
bool>();
363 q.vflip = part.at(
"vflip").get<
bool>();
366 if (positions.size() != asset.
cells.size())
367 return Invalid(
"Import grid has missing cells");
368 const auto status = ValidateAsset(asset);
372 }
catch (
const std::exception& error) {
373 return Invalid(std::string(
"Invalid graphics import: ") + error.what());
415 auto status = ValidateAsset(asset);
420 context.
grid.size() !=
427 return Invalid(
"Invalid import context or anchor");
439 return Invalid(
"Invalid caller-owned graphics or Tile16 range");
441 for (
int i = 0; i < resource_count; ++i) {
444 return absl::FailedPreconditionError(
445 "Pending resource differs from the resolved current map; refresh "
453 std::map<std::vector<uint8_t>,
int> encoded;
455 std::map<std::string, Group> groups;
456 std::set<int> rows, allocated;
463 for (
const auto& source : asset.
palettes) {
465 for (
const auto& binding : bindings.
palettes) {
466 if (binding.palette == source.id) {
468 return Invalid(
"Duplicate logical palette destination");
472 if (!selected || selected->
bg_row < 2 || selected->
bg_row > 7 ||
473 !rows.insert(selected->
bg_row).second ||
481 "Missing, overlapping or out-of-range owned palette/CHR destination");
484 source, *selected,
static_cast<int>(source.characters.size() / 32), {}};
487 return Invalid(
"Owned character ranges overlap");
489 for (
int i = 0; i < group.next; ++i) {
491 const auto begin = source.characters.begin() + i * 32;
492 std::vector<uint8_t> encoded(begin, begin + 32);
493 std::copy(encoded.begin(), encoded.end(),
496 characters[c] = Decode(source.characters, i);
497 group.encoded.emplace(std::move(encoded), c);
499 groups.emplace(source.id, std::move(group));
501 if (groups.size() != bindings.
palettes.size())
502 return Invalid(
"Unknown palette destination");
503 std::map<int, std::vector<uint16_t>> backgrounds;
504 for (
auto id : context.
grid) {
506 return Invalid(
"Current grid references an unknown Tile16");
507 if (!backgrounds.contains(
id)) {
511 return colors.status();
512 backgrounds.emplace(
id, std::move(*colors));
517 std::map<std::array<uint16_t, 4>,
int> imported;
518 std::vector<std::vector<uint16_t>> expected(asset.
cells.size());
520 for (
int y = 0; y < asset.
height; ++y) {
521 for (
int x = 0; x < asset.
width; ++x) {
522 const int at = y * asset.
width + x;
527 expected[at] = backgrounds.at(old_id);
529 for (
int q = 0; q < 4; ++q) {
530 const auto& source = asset.
cells[at].quadrants[q];
531 if (source.transparent)
533 auto& group = groups.at(source.palette);
534 auto pixels = Flip(Decode(group.source.characters, source.character),
535 source.hflip, source.vflip);
536 const bool any = std::any_of(pixels.begin(), pixels.end(),
537 [](
int p) { return p != 0 && p != 8; });
541 const bool alpha = std::any_of(pixels.begin(), pixels.end(),
542 [](
int p) { return p == 0 || p == 8; });
543 for (
int i = 0; i < 64; ++i) {
544 const int location = (q / 2 * 8 + i / 8) * 16 + q % 2 * 8 + i % 8;
545 if (pixels[i] == 0 || pixels[i] == 8) {
546 const auto color = expected[at][location];
547 int found = -1, free = -1;
548 for (
int p = 1; p < 16; ++p) {
551 if (group.source.colors[p] == color)
553 if (!group.source.colors[p] && free == -1)
558 return absl::FailedPreconditionError(
559 "Partial-alpha background color does not fit the logical "
560 "palette; no recoloring applied");
562 group.source.colors[found] = color;
566 expected[at][location] = *group.source.colors[pixels[i]];
569 int c = group.binding.first_character + source.character;
570 bool h = source.hflip, v = source.vflip;
572 Pixels canonical = pixels;
576 {std::pair{
true,
false}, {
false,
true}, {
true,
true}}) {
577 auto candidate = Flip(pixels, flags.first, flags.second);
578 if (candidate < canonical) {
579 canonical = candidate;
584 auto encoded = Encode(canonical);
585 const auto existing = group.encoded.find(encoded);
586 if (existing != group.encoded.end())
587 c = existing->second;
589 if (group.next >= group.binding.owned_character_count)
590 return absl::FailedPreconditionError(
591 "Exact alpha composition exceeds the caller-owned character "
593 c = group.binding.first_character + group.next++;
594 group.encoded.emplace(encoded, c);
595 characters[c] = canonical;
596 std::copy(encoded.begin(), encoded.end(),
608 const auto words = Words(tile);
609 auto found = imported.find(words);
611 if (found != imported.end())
615 return absl::FailedPreconditionError(
616 "Import exceeds the caller-owned Tile16 range");
618 imported.emplace(words,
id);
619 if (definitions[
id] != tile)
621 definitions[id] = tile;
626 auto palette = context.
palette;
627 std::map<ColorKey, gfx::PaletteColorChange> changes;
628 for (
const auto& [
id, group] : groups) {
629 for (
int p = 1; p < 16; ++p) {
630 if (p == 8 || !group.source.colors[p])
632 const int index = group.binding.bg_row * 16 + p;
635 return absl::FailedPreconditionError(
636 "Selected palette color has no writable resolved source");
639 return original.status();
640 if (original->snes() != context.
palette[index])
641 return absl::FailedPreconditionError(
642 "Resolved palette changed since planning; refresh the current map");
644 address->palette_index, address->color_index};
651 const auto [at, inserted] = changes.emplace(key, change);
652 if (!inserted && at->second.new_color.snes() != change.new_color.snes())
654 "Logical palettes require conflicting writes to a shared saved "
658 for (
int i = 0; i < 128; ++i) {
663 a.palette_index, a.color_index});
664 if (change != changes.end())
665 palette[i] = change->second.new_color.snes();
667 for (
const auto& [key, change] : changes) {
668 if (change.original_color.snes() != change.new_color.snes())
675 for (
int x = 0; x < context.
grid_width; ++x) {
679 sx >= 0 && sy >= 0 && sx < asset.
width && sy < asset.
height;
680 const int id = inside ? plan.
stamp.
at(sx, sy) : old;
681 auto rendered = RenderColors(definitions[
id], characters, palette);
683 return rendered.status();
685 inside ? expected[sy * asset.
width + sx] : backgrounds.at(old);
686 if (*rendered != wanted) {
687 const auto mismatch =
688 std::mismatch(rendered->begin(), rendered->end(), wanted.begin());
689 const int pixel =
static_cast<int>(mismatch.first - rendered->begin());
690 return absl::FailedPreconditionError(absl::StrFormat(
691 "Import pixel mismatch %s: area tile=(%d,%d), Tile16=0x%03X "
692 "(previous=0x%03X), pixel=(%d,%d), wanted BGR555=0x%04X, "
693 "rendered BGR555=0x%04X",
694 inside ?
"inside import stamp" :
"outside existing consumer", x, y,
695 id, old, pixel % 16, pixel / 16, *mismatch.second,
700 for (
int i = 0; i < resource_count; ++i) {
709 for (
int q = 0; q < 4; ++q)
713 for (
auto id : context.
grid) {
714 for (
int q = 0; q < 4; ++q)
720 plan.
notes.push_back(
721 "Only caller-owned ranges are planned; current-map pixel equality does "
722 "not prove global allocation or collision ownership.");