Porytiles
Loading...
Searching...
No Matches
tileset_compiler.cpp
Go to the documentation of this file.
2
3#include <array>
4#include <format>
5#include <iostream>
6#include <map>
7#include <memory>
8#include <ranges>
9#include <set>
10#include <unordered_map>
11#include <unordered_set>
12#include <vector>
13
47
48namespace {
49
50using namespace porytiles;
51
65[[nodiscard]] std::unique_ptr<PackingStrategy> make_packing_strategy(
66 PackingStrategyType strategy_type, const PackingStrategyParams &params, const UserDiagnostics &diag)
67{
68 switch (strategy_type) {
69 case PackingStrategyType::best_fusion:
70 return std::make_unique<BestFusionStrategy>();
71 case PackingStrategyType::backtracking: {
72 const auto &cfg = params.backtracking;
73 if (!cfg.has_any()) {
74 return std::make_unique<BacktrackingStrategy>(&diag);
75 }
76 return std::make_unique<BacktrackingStrategy>(
77 cfg.search_algorithm.value.value_or(SearchAlgorithm::dfs),
78 cfg.node_cutoff.value.value_or(1'000'000),
79 cfg.best_branches.value.value_or(std::numeric_limits<std::size_t>::max()),
80 cfg.smart_prune.value.value_or(true),
81 &diag);
82 }
83 case PackingStrategyType::overload_and_remove: {
84 const auto &cfg = params.overload_and_remove;
85 if (!cfg.has_any()) {
86 return std::make_unique<OverloadAndRemoveStrategy>(&diag);
87 }
88 return std::make_unique<OverloadAndRemoveStrategy>(
89 cfg.max_attempts.value.value_or(20),
90 cfg.seed.value.value_or(42),
91 cfg.shuffle_strategy.value.value_or(ShuffleStrategy::noisy_ffd),
92 &diag);
93 }
94 }
95 panic("Unhandled PackingStrategyType value.");
96}
97
105struct TileAssignmentResult {
106 enum class Status { success, no_covering_pal, tile_not_found, tile_limit_reached };
107
108 Status status{Status::success};
109 std::optional<TilemapEntry> entry{};
110
111 // Error reporting data (populated on failure)
112 std::vector<PaletteMatchResult<Rgba32>> match_results{};
113 PixelTile<IndexPixel> index_tile{};
114 std::size_t pal_index{0};
115 Palette<Rgba32, pal::max_size> matched_pal{};
116};
117
125struct AnimKeyframeData {
126 std::vector<CanonicalPixelTile<IndexPixel>> tiles;
127 std::vector<const Palette<Rgba32, pal::max_size> *> palettes;
128 std::vector<std::size_t> pal_indices;
129};
130
142class CompilerTask {
143 public:
144 CompilerTask(
145 const Tileset &tileset,
146 bool is_secondary,
147 const Tileset *paired_primary,
148 const TextFormatter &format,
149 const UserDiagnostics &diag,
150 const TilePrinter &tile_printer,
151 const PalettePrinter &pal_printer,
152 const DomainConfig &config)
153 : tileset_{tileset}, is_secondary_{is_secondary}, paired_primary_{paired_primary}, format_{format}, diag_{diag},
154 tile_printer_{tile_printer}, pal_printer_{pal_printer}, config_{config}, extrinsic_transparency_{},
155 num_pals_in_primary_{}, num_pals_total_{}, num_metatiles_in_primary_{}, num_tiles_in_primary_{},
156 num_tiles_per_metatile_{}, pal_hints_enabled_{}, pal_hints_{}
157 {
158 }
159
160 [[nodiscard]] ChainableResult<std::unique_ptr<Tileset>> run();
161
162 private:
163 // Pipeline steps
164 [[nodiscard]] ChainableResult<void> pipeline_step_process_porytiles_input();
165 [[nodiscard]] ChainableResult<void> pipeline_step_process_porymap_input();
166 [[nodiscard]] ChainableResult<void> pipeline_step_validate_input();
167 [[nodiscard]] ChainableResult<void> pipeline_step_setup_working_data();
168 [[nodiscard]] ChainableResult<void> pipeline_step_match_tiles_pals();
169 [[nodiscard]] std::unique_ptr<Tileset> pipeline_step_assemble_output();
170
171 // Pipeline helpers - tile matching
172 [[nodiscard]] std::optional<TilemapEntry> pipeline_helper_try_reuse_porymap_tile(std::size_t tile_index);
173 [[nodiscard]] TileAssignmentResult
174 pipeline_helper_assign_tile_via_pal_match(const PixelTile<Rgba32> &porytiles_tile, std::size_t flat_index);
175
176 // Pipeline helpers - palette packing
177 [[nodiscard]] ChainableResult<void> pipeline_helper_run_pal_packing();
179 pipeline_helper_build_color_index_map(const std::vector<PaletteHint> &hints, std::size_t color_count_limit) const;
180 // Pipeline helpers - animation processing
181 [[nodiscard]] ChainableResult<void> pipeline_helper_register_animations();
183 pipeline_helper_build_keyframe_data(const std::string &anim_name, const Animation<Rgba32> &anim) const;
184 [[nodiscard]] ChainableResult<void> pipeline_helper_validate_primary_anim_subtile_coverage() const;
185 void pipeline_helper_compile_animations();
186 void pipeline_helper_apply_manual_overrides();
187
188 // Pipeline helpers - true_color mode
189 void pipeline_helper_apply_true_color_to_tiles_png();
190
191 [[nodiscard]] bool is_secondary() const
192 {
193 return is_secondary_;
194 }
195
196 [[nodiscard]] bool has_paired_primary() const
197 {
198 return paired_primary_ != nullptr;
199 }
200
201 // Pipeline helpers - error emission
202 void pipeline_helper_emit_no_matching_tile_error(
203 std::size_t tile_index,
204 const PixelTile<IndexPixel> &index_tile,
205 std::size_t pal_index,
206 const Palette<Rgba32, pal::max_size> &matched_pal);
207 void pipeline_helper_emit_no_matching_pal_error(
208 std::size_t tile_index, const std::vector<PaletteMatchResult<Rgba32>> &matches);
209 void pipeline_helper_emit_tile_limit_error(std::size_t tile_index, std::size_t tile_limit);
210
211 // Dependencies (injected in ctor)
212 const Tileset &tileset_;
213 bool is_secondary_;
214 const Tileset *paired_primary_;
215 const TextFormatter &format_;
216 const UserDiagnostics &diag_;
217 const TilePrinter &tile_printer_;
218 const PalettePrinter &pal_printer_;
219 const DomainConfig &config_;
220
221 // Config values (populated in run())
222 ConfigValue<Rgba32> extrinsic_transparency_;
223 ConfigValue<Rgba32> paired_primary_extrinsic_transparency_{};
224 ConfigValue<std::size_t> num_pals_in_primary_;
225 ConfigValue<std::size_t> num_pals_total_;
226 ConfigValue<std::size_t> num_metatiles_in_primary_;
227 ConfigValue<std::size_t> num_tiles_in_primary_;
228 ConfigValue<std::size_t> num_tiles_total_;
229 ConfigValue<std::size_t> num_tiles_per_metatile_;
230 ConfigValue<bool> pal_hints_enabled_;
232 ConfigValue<ArtifactEditMode> tiles_edit_mode_;
233 ConfigValue<ArtifactEditMode> pals_edit_mode_;
234 ConfigValue<TilesPalMode> tiles_pal_mode_;
235 ConfigValue<FrameLinking> global_frame_linking_;
236 ConfigValue<PerAnimOverrides> per_anim_overrides_;
237 ConfigValue<bool> cross_tileset_anim_linking_;
238
239 // Intermediate state - Porytiles
240 std::vector<Metatile<Rgba32>> porytiles_metatiles_{};
241 std::vector<PixelTile<Rgba32>> porytiles_pixel_rgba_{};
242 std::vector<CanonicalPixelTile<Rgba32>> porytiles_canonical_pixel_rgba_{};
243
244 // Intermediate state - Porymap
245 std::vector<TilemapEntry> porymap_tilemap_entries_{};
246 std::vector<Metatile<Rgba32>> porymap_metatiles_{};
247 std::vector<PixelTile<Rgba32>> porymap_pixel_rgba_{};
248 std::vector<CanonicalPixelTile<Rgba32>> porymap_canonical_pixel_rgba_{};
249 std::array<Palette<Rgba32, pal::max_size>, pal::num_pals> new_porymap_pals_{};
250 std::map<std::size_t, std::size_t> tile_to_pal_{};
251
252 // Working data
253 std::unique_ptr<PorymapTilesetComponent> new_porymap_component_{};
254 std::unique_ptr<TilesPngWorkspace> tiles_workspace_{};
255 AnimTileMatcher anim_tile_matcher_{};
256 std::map<std::string, std::vector<std::size_t>> anim_pal_indices_{};
257};
258
260{
261 // Unwrap config values
262 PT_UNWRAP_TILESET_CONFIG_REF(config_, extrinsic_transparency, tileset_.name(), std::unique_ptr<Tileset>);
263 PT_UNWRAP_TILESET_CONFIG_REF(config_, num_pals_in_primary, tileset_.name(), std::unique_ptr<Tileset>);
264 PT_UNWRAP_TILESET_CONFIG_REF(config_, num_pals_total, tileset_.name(), std::unique_ptr<Tileset>);
265 PT_UNWRAP_TILESET_CONFIG_REF(config_, num_metatiles_in_primary, tileset_.name(), std::unique_ptr<Tileset>);
266 PT_UNWRAP_TILESET_CONFIG_REF(config_, num_tiles_in_primary, tileset_.name(), std::unique_ptr<Tileset>);
267 PT_UNWRAP_TILESET_CONFIG_REF(config_, num_tiles_total, tileset_.name(), std::unique_ptr<Tileset>);
268 PT_UNWRAP_TILESET_CONFIG_REF(config_, num_tiles_per_metatile, tileset_.name(), std::unique_ptr<Tileset>);
269 PT_UNWRAP_TILESET_CONFIG_REF(config_, pal_hints_enabled, tileset_.name(), std::unique_ptr<Tileset>);
270 PT_UNWRAP_TILESET_CONFIG_REF(config_, pal_hints, tileset_.name(), std::unique_ptr<Tileset>);
271 PT_UNWRAP_TILESET_CONFIG_REF(config_, tiles_edit_mode, tileset_.name(), std::unique_ptr<Tileset>);
272 PT_UNWRAP_TILESET_CONFIG_REF(config_, pals_edit_mode, tileset_.name(), std::unique_ptr<Tileset>);
273 PT_UNWRAP_TILESET_CONFIG_REF(config_, tiles_pal_mode, tileset_.name(), std::unique_ptr<Tileset>);
274 PT_UNWRAP_TILESET_CONFIG_REF(config_, global_frame_linking, tileset_.name(), std::unique_ptr<Tileset>);
275 PT_UNWRAP_TILESET_CONFIG_REF(config_, per_anim_overrides, tileset_.name(), std::unique_ptr<Tileset>);
276 PT_UNWRAP_TILESET_CONFIG_REF(config_, cross_tileset_anim_linking, tileset_.name(), std::unique_ptr<Tileset>);
277
278 extrinsic_transparency_ = extrinsic_transparency;
279 num_pals_in_primary_ = num_pals_in_primary;
280 num_pals_total_ = num_pals_total;
281 num_metatiles_in_primary_ = num_metatiles_in_primary;
282 num_tiles_in_primary_ = num_tiles_in_primary;
283 num_tiles_total_ = num_tiles_total;
284 num_tiles_per_metatile_ = num_tiles_per_metatile;
285 pal_hints_enabled_ = pal_hints_enabled;
286 pal_hints_ = pal_hints;
287 tiles_edit_mode_ = tiles_edit_mode;
288 pals_edit_mode_ = pals_edit_mode;
289 tiles_pal_mode_ = tiles_pal_mode;
290 global_frame_linking_ = global_frame_linking;
291 per_anim_overrides_ = per_anim_overrides;
292 cross_tileset_anim_linking_ = cross_tileset_anim_linking;
293
294 /*
295 * Resolve the paired primary's ET if applicable. This is needed for cross-tileset animation linking so that
296 * primary subtiles are classified as transparent/opaque under the primary's own ET rather than the secondary's.
297 * Using each tileset's own ET is what makes the cross-ET comparator on the matcher's lookup map find matches
298 * across mismatched-ET inputs.
299 */
300 if (has_paired_primary()) {
301 paired_primary_extrinsic_transparency_ =
302 config_.extrinsic_transparency(ConfigScopeType::tileset, paired_primary_->name()).value();
303 }
304
305 // Execute subtasks
306 PT_TRY_CALL_PASS_ERR(pipeline_step_process_porytiles_input(), std::unique_ptr<Tileset>);
307
308 PT_TRY_CALL_PASS_ERR(pipeline_step_process_porymap_input(), std::unique_ptr<Tileset>);
309
310 PT_TRY_CALL_PASS_ERR(pipeline_step_validate_input(), std::unique_ptr<Tileset>);
311
312 PT_TRY_CALL_PASS_ERR(pipeline_step_setup_working_data(), std::unique_ptr<Tileset>);
313
314 PT_TRY_CALL_PASS_ERR(pipeline_step_match_tiles_pals(), std::unique_ptr<Tileset>);
315
316 return pipeline_step_assemble_output();
317}
318
319ChainableResult<void> CompilerTask::pipeline_step_process_porytiles_input()
320{
321 LayerImageMetatileizer<Rgba32> metatileizer{};
322
323 // Read Porytiles layer images into metatile vector
325 metatiles,
326 metatileizer.metatileize(
327 tileset_.porytiles_component().bottom(),
328 tileset_.porytiles_component().middle(),
329 tileset_.porytiles_component().top()),
330 void,
331 "failed to metatileize input layer images for " + tileset_.name());
332 porytiles_metatiles_ = std::move(metatiles);
333
334 // Decompose Porytiles metatiles and generate canonical versions
335 porytiles_pixel_rgba_ = metatile::decompose(porytiles_metatiles_);
336 porytiles_canonical_pixel_rgba_ = transform<CanonicalPixelTile<Rgba32>>(porytiles_pixel_rgba_);
337
338 return {};
339}
340
341ChainableResult<void> CompilerTask::pipeline_step_process_porymap_input()
342{
343 LayerModeConverter layer_mode_converter{&format_, &diag_, &tile_printer_, extrinsic_transparency_};
344 MetatileDecompiler metatile_decompiler{&format_, &diag_, &tile_printer_, extrinsic_transparency_};
345
346 // Decompile Porymap tilemap entries and decompose into tile vector
348 tilemap_entries,
349 layer_mode_converter.triple_layerize(tileset_.porymap_component()),
350 void,
351 std::format("Failed to triple-layerize Porymap component for tileset '{}'.", tileset_.name()));
352 porymap_tilemap_entries_ = std::move(tilemap_entries);
353
355 metatiles,
356 metatile_decompiler.decompile_metatiles(
357 porymap_tilemap_entries_, tileset_.porymap_component().tiles_png(), tileset_.porymap_component().pals()),
358 void,
359 std::format("Failed to decompile Porymap component for tileset '{}'.", tileset_.name()));
360 porymap_metatiles_ = std::move(metatiles);
361
362 /*
363 * We don't need to run any validation (including size validation) on porymap_metatiles here. We're going to
364 * overwrite them anyway. We only need to check the size of the final tilemap entry vector. Patch builds don't need
365 * to preserve tilemap entries since those cannot be referenced by other tilesets. We can just write a new entry
366 * vector every time.
367 */
368
369 // Decompose Porymap metatiles and generate canonical versions
370 porymap_pixel_rgba_ = metatile::decompose(porymap_metatiles_);
371 porymap_canonical_pixel_rgba_ = transform<CanonicalPixelTile<Rgba32>>(porymap_pixel_rgba_);
372
373 return {};
374}
375
376ChainableResult<void> CompilerTask::pipeline_step_validate_input()
377{
378 TilesetCompileValidatorServices services{config_, diag_, tile_printer_, pal_printer_};
379
380 /*
381 * Reject mode combinations that this compiler does not support before running any content-based validation. This
382 * function is the single source of truth for which compile mode combinations are supported.
383 */
384
385 if (is_secondary() && tiles_edit_mode_ != ArtifactEditMode::optimize) {
386 std::vector<std::string> err_msg{};
387 err_msg.emplace_back(format_.format(
388 "Secondary compilation of tileset '{}' does not yet support tiles edit mode '{}'.",
389 FormatParam{tileset_.name(), Style::bold},
390 FormatParam{to_string(tiles_edit_mode_.value()), Style::bold}));
391 err_msg.emplace_back(format_.format(
392 "For now, only '{}' is supported for secondary tilesets.", FormatParam{"optimize", Style::bold}));
393 err_msg.emplace_back(format_.format(
394 "Support for '{}' and '{}' is planned for a future update.",
395 FormatParam{"locked", Style::bold},
396 FormatParam{"patch", Style::bold}));
397 err_msg.append_range(format_config_note_with_separator(format_, tiles_edit_mode_));
398 return FormattableError{err_msg};
399 }
400
401 if (is_secondary() && pals_edit_mode_ != ArtifactEditMode::optimize) {
402 std::vector<std::string> err_msg{};
403 err_msg.emplace_back(format_.format(
404 "Secondary compilation of tileset '{}' does not yet support pals edit mode '{}'.",
405 FormatParam{tileset_.name(), Style::bold},
406 FormatParam{to_string(pals_edit_mode_.value()), Style::bold}));
407 err_msg.emplace_back(format_.format(
408 "For now, only '{}' is supported for secondary tilesets.", FormatParam{"optimize", Style::bold}));
409 err_msg.emplace_back(format_.format(
410 "Support for '{}' and '{}' is planned for a future update.",
411 FormatParam{"locked", Style::bold},
412 FormatParam{"patch", Style::bold}));
413 err_msg.append_range(format_config_note_with_separator(format_, pals_edit_mode_));
414 return FormattableError{err_msg};
415 }
416
417 if (pals_edit_mode_ == ArtifactEditMode::patch) {
418 std::vector<std::string> err_msg{};
419 err_msg.emplace_back(format_.format(
420 "Tileset '{}' uses pals edit mode '{}', which is not yet implemented.",
421 FormatParam{tileset_.name(), Style::bold},
422 FormatParam{to_string(pals_edit_mode_.value()), Style::bold}));
423 err_msg.append_range(format_config_note_with_separator(format_, pals_edit_mode_));
424 return FormattableError{err_msg};
425 }
426
427 if (pals_edit_mode_ == ArtifactEditMode::optimize && tiles_edit_mode_ == ArtifactEditMode::locked) {
428 std::vector<std::string> err_msg{};
429 err_msg.emplace_back(format_.format(
430 "Tileset '{}' uses palettes edit mode '{}' with tiles edit mode '{}', which is not a valid combination.",
431 FormatParam{tileset_.name(), Style::bold},
432 FormatParam{"optimize", Style::bold},
433 FormatParam{"locked", Style::bold}));
434 err_msg.emplace_back(
435 "Tiles are fundamentally dependent on palettes, so optimizing palettes while keeping "
436 "tiles locked is not coherent.");
437 err_msg.append_range(format_config_note(format_, pals_edit_mode_));
438 err_msg.append_range(format_config_note_with_separator(format_, tiles_edit_mode_));
439 return FormattableError{err_msg};
440 }
441
442 // Run metatile count validation
444 validate_metatile_count(services, tileset_.name(), is_secondary(), porytiles_metatiles_), void);
445
446 std::size_t pal_start = is_secondary() ? num_pals_in_primary_.value() : 0;
447
448 /*
449 * For secondary compiles, validate the paired primary's Porymap palettes before validating the secondary's own
450 * palettes. The paired primary's palettes are loaded directly into the palette packer as pre-filled slots, so if
451 * they contain the extrinsic transparency color in a non-slot-0 position the packer will panic. Running
452 * validate_porymap_pal here turns that crash into a proper diagnostic scoped to the primary's name. This runs
453 * unconditionally since secondary compilation always consumes the primary's Porymap palettes.
454 */
455 if (is_secondary() && has_paired_primary()) {
456 for (std::size_t pal_index = 0; pal_index < num_pals_in_primary_.value(); ++pal_index) {
459 services,
460 paired_primary_->name(),
461 paired_primary_->porymap_component().pal_at(pal_index),
462 pal_index),
463 void);
464 }
465 }
466
467 if (pals_edit_mode_ != ArtifactEditMode::optimize) {
468 // Validate Porymap pals if user is asking for pals:locked or pals:patch
469 for (std::size_t pal_index = pal_start; pal_index < tileset_.porymap_component().pals().size(); ++pal_index) {
472 services, tileset_.name(), tileset_.porymap_component().pals().at(pal_index), pal_index),
473 void);
474 }
475 }
476
477 // Fail fast if secondary tileset defines an override palette in a primary slot
478 if (is_secondary()) {
479 for (std::size_t pal_index = 0; pal_index < num_pals_in_primary_.value(); ++pal_index) {
480 if (pal_index < tileset_.porytiles_component().pals().size() &&
481 tileset_.porytiles_component().pals().at(pal_index).has_value()) {
482 return FormattableError{
483 "Secondary tileset '{}' defines a Porytiles override palette in primary slot '{}'.",
484 FormatParam{tileset_.name(), Style::bold},
486 }
487 }
488 }
489
490 // Validate Porytiles pals (skip primary slots for secondary)
491 for (std::size_t pal_index = pal_start; pal_index < tileset_.porytiles_component().pals().size(); ++pal_index) {
492 if (tileset_.porytiles_component().pals().at(pal_index).has_value()) {
495 services, tileset_.name(), tileset_.porytiles_component().pals().at(pal_index).value(), pal_index),
496 void);
497 }
498 }
499
500 // Validate palette hints
501 for (const auto &hint : pal_hints_.value()) {
502 PT_TRY_CALL_PASS_ERR(validate_pal_hint(services, tileset_.name(), hint), void);
503 }
504
505 // Run alpha channel validation
508 services, tileset_.name(), porytiles_metatiles_, tileset_.porytiles_component().anims()),
509 void);
510
511 // Run layer mode validation
512 PT_TRY_CALL_PASS_ERR(validate_layer_mode(services, tileset_.name(), porytiles_metatiles_), void);
513
514 // Run tile color count validation
517 services, tileset_.name(), porytiles_metatiles_, tileset_.porytiles_component().anims()),
518 void);
519
520 // Run global color count validation
523 services,
524 tileset_.name(),
525 is_secondary(),
526 porytiles_metatiles_,
527 tileset_.porytiles_component().anims(),
528 tileset_.porytiles_component().pals(),
529 pal_hints_.value()),
530 void);
531
532 // Run precision loss validation
535 services,
536 tileset_.name(),
537 porytiles_metatiles_,
538 tileset_.porytiles_component().anims(),
539 tileset_.porytiles_component().pals(),
540 pal_hints_.value(),
541 std::nullopt),
542 void);
543
544 // Run animation validation
545 PT_TRY_CALL_PASS_ERR(validate_anim_frames(services, tileset_.name(), tileset_.porytiles_component().anims()), void);
546
547 return {};
548}
549
550ChainableResult<void> CompilerTask::pipeline_step_setup_working_data()
551{
552 // Create palettes
553 if (pals_edit_mode_ == ArtifactEditMode::locked) {
554 // Collect all palettes from existing Porymap component
555 for (std::size_t i = 0; i < pal::num_pals; i++) {
556 new_porymap_pals_[i] = tileset_.porymap_component().pals()[i];
557 }
558 }
559 else if (pals_edit_mode_ == ArtifactEditMode::optimize) {
560 PT_TRY_CALL_PASS_ERR(pipeline_helper_run_pal_packing(), void);
561 }
562 else {
563 panic("unexpected pals ArtifactEditMode");
564 }
565
566 // Create tiles workspace
567 if (tiles_edit_mode_ == ArtifactEditMode::locked) {
568 /*
569 * When tiles are locked, compute the exact size of tiles.png so we keep it completely unchanged. When we
570 * output, we'll also set ExportTrimMode::include_trailing_transparent so that if there was transparency at
571 * the end, we don't remove it.
572 */
573 const auto size_in_tiles = tileset_.porymap_component().tiles_png().size_in_tiles();
574 tiles_workspace_ = std::make_unique<TilesPngWorkspace>(tileset_.porymap_component().tiles_png(), size_in_tiles);
575 }
576 else if (tiles_edit_mode_ == ArtifactEditMode::patch) {
577 tiles_workspace_ = std::make_unique<TilesPngWorkspace>(
578 tileset_.porymap_component().tiles_png(), num_tiles_in_primary_.value());
579 }
580 else if (tiles_edit_mode_ == ArtifactEditMode::optimize) {
581 if (is_secondary()) {
582 if (has_paired_primary()) {
583 tiles_workspace_ = std::make_unique<TilesPngWorkspace>(TilesPngWorkspace::for_secondary(
584 paired_primary_->porymap_component().tiles_png(),
585 num_tiles_in_primary_.value(),
586 num_tiles_total_.value()));
587 }
588 else {
589 tiles_workspace_ = std::make_unique<TilesPngWorkspace>(TilesPngWorkspace::for_standalone_secondary(
590 num_tiles_in_primary_.value(), num_tiles_total_.value()));
591 }
592 }
593 else {
594 tiles_workspace_ = std::make_unique<TilesPngWorkspace>(num_tiles_in_primary_.value());
595 }
596 }
597 else {
598 panic("unexpected tiles_edit_mode");
599 }
600
601 // Register animations (reserve slots, compile keyframes, register matcher)
602 // Must be done before regular tile matching so animation slots are reserved
603 PT_TRY_CALL_CHAIN_ERR(pipeline_helper_register_animations(), void, "Failed to register animations.");
604
605 // Create new Porymap component for output
606 new_porymap_component_ = std::make_unique<PorymapTilesetComponent>();
607
608 return {};
609}
610
611ChainableResult<void> CompilerTask::pipeline_step_match_tiles_pals()
612{
613 bool matched_all_tiles = true;
614 for (std::size_t i = 0; i < porytiles_pixel_rgba_.size(); i++) {
615 const auto &porytiles_tile = porytiles_pixel_rgba_[i];
616
617 // In non-optimize mode, first try to reuse existing porymap tile
618 if (tiles_edit_mode_ != ArtifactEditMode::optimize) {
619 if (const auto maybe_tilemap_entry = pipeline_helper_try_reuse_porymap_tile(i);
620 maybe_tilemap_entry.has_value()) {
621 new_porymap_component_->push_back_tilemap_entry(maybe_tilemap_entry.value());
622 continue;
623 }
624 }
625
626 /*
627 * Transparent tiles always map to tile index 0 (the reserved transparent tile).
628 *
629 * If tile 0 transparency is a pokeemerald convention, why does this come after the
630 * pipeline_helper_try_reuse_porymap_tile step for non-tiles-optimize builds? It's because Porytiles design
631 * philosophy prioritizes surgical edits where possible. A user could have other locations in tiles.png marked
632 * transparent in addition to tile 0. If one of their metatiles referenced one of these alternate locations, we
633 * don't want to create a diff by forcing the metatile reference to change to tile 0. Instead, we'll just
634 * respect the idiosyncrasy by calling pipeline_helper_try_reuse_porymap_tile and letting it match there first.
635 */
636 if (porytiles_tile.is_transparent(extrinsic_transparency_.value())) {
637 new_porymap_component_->push_back_tilemap_entry(TilemapEntry{0, 0, false, false});
638 continue;
639 }
640
641 // Assign via palette matching (shared logic for all modes)
642 const auto tile_assignment_result = pipeline_helper_assign_tile_via_pal_match(porytiles_tile, i);
643
644 switch (tile_assignment_result.status) {
645 case TileAssignmentResult::Status::success:
646 new_porymap_component_->push_back_tilemap_entry(tile_assignment_result.entry.value());
647 break;
648
649 case TileAssignmentResult::Status::no_covering_pal:
650 if (pals_edit_mode_ == ArtifactEditMode::optimize) {
651 panic("ArtifactEditMode::optimize but no covering pal found - this should have failed at packing step");
652 }
653 matched_all_tiles = false;
654 pipeline_helper_emit_no_matching_pal_error(i, tile_assignment_result.match_results);
655 break;
656
657 case TileAssignmentResult::Status::tile_not_found:
658 matched_all_tiles = false;
659 pipeline_helper_emit_no_matching_tile_error(
660 i,
661 tile_assignment_result.index_tile,
662 tile_assignment_result.pal_index,
663 tile_assignment_result.matched_pal);
664 break;
665
666 case TileAssignmentResult::Status::tile_limit_reached:
667 matched_all_tiles = false;
668 {
669 const std::size_t user_visible_tile_limit =
670 is_secondary() ? (num_tiles_total_.value() - num_tiles_in_primary_.value())
671 : num_tiles_in_primary_.value();
672 pipeline_helper_emit_tile_limit_error(i, user_visible_tile_limit);
673 }
674 break;
675 }
676
677 // Early exit on tile limit, no point printing a bazillion "limit hit" errors after first one
678 if (tile_assignment_result.status == TileAssignmentResult::Status::tile_limit_reached) {
679 break;
680 }
681 }
682
683 if (!matched_all_tiles) {
684 return ChainableResult<void>{FormattableError{"Failed to match all Porytiles tiles."}};
685 }
686
687 /*
688 * Catches unreferenced non-transparent animation subtiles at primary compile time, rather than letting the failure
689 * surface from a paired secondary compile with a confusing primary-pointing error. Secondary compiles still keep
690 * the defense-in-depth check in pipeline_helper_register_animations.
691 */
692 if (!is_secondary()) {
693 PT_TRY_CALL_PASS_SAME_ERR(pipeline_helper_validate_primary_anim_subtile_coverage());
694 }
695
696 return {};
697}
698
699std::unique_ptr<Tileset> CompilerTask::pipeline_step_assemble_output()
700{
701 auto new_porytiles_component = std::make_unique<PorytilesTilesetComponent>(tileset_.porytiles_component());
702
703 // Update porytiles component animation params with computed tile offsets
704 for (auto &[anim_name, anim] : new_porytiles_component->anims()) {
705 if (auto maybe_offset = anim_tile_matcher_.tile_offset_for(anim_name); maybe_offset.has_value()) {
706 AnimParams updated_params = anim.params();
707 const std::size_t local_offset =
708 is_secondary() ? maybe_offset.value() - num_tiles_in_primary_.value() : maybe_offset.value();
709 updated_params.tile_offset(local_offset);
710 anim.params(std::move(updated_params));
711 }
712 }
713
714 // Compile animations from Porytiles format to Porymap format
715 pipeline_helper_compile_animations();
716
717 // Apply manual animation overrides to metatiles_bin (must happen before dual-layerization)
718 pipeline_helper_apply_manual_overrides();
719
720 /*
721 * If user is requesting dual-layer, use the input Porytiles-format metatiles to infer the LayerType for each
722 * metatile and remove the relevant tilemap entries. Here, we assume that the Porytiles metatiles have already been
723 * validated in an earlier step as dual-layer compatible.
724 */
725 LayerModeConverter layer_mode_converter{&format_, &diag_, &tile_printer_, extrinsic_transparency_};
726 const auto configured_layer_mode = layer_mode_from_val(num_tiles_per_metatile_);
727 if (configured_layer_mode == LayerMode::dual) {
728 const auto &dual_layerized =
729 layer_mode_converter.dual_layerize(new_porymap_component_->metatiles_bin(), porytiles_metatiles_);
730 new_porymap_component_->metatiles_bin(dual_layerized);
731 }
732
733 // Copy metatile attributes from original
734 for (std::size_t i = 0; i < porytiles_metatiles_.size(); i++) {
735 const auto &metatile = porytiles_metatiles_[i];
736 LayerType layer_type;
737 if (configured_layer_mode == LayerMode::dual) {
738 layer_type = metatile.infer_layer_type(extrinsic_transparency_.value());
739 }
740 else {
741 layer_type = LayerType::normal;
742 }
743 const auto maybe_porytiles_attr = tileset_.porytiles_component().get_attribute(i);
744 MetatileAttribute new_attr{};
745 if (maybe_porytiles_attr.has_value()) {
746 new_attr = maybe_porytiles_attr.value();
747 }
748 new_attr.layer_type(layer_type);
749 new_porymap_component_->push_back_attribute(new_attr);
750 }
751
752 // Export tiles in original form
753 if (tiles_edit_mode_ == ArtifactEditMode::optimize) {
754 if (is_secondary()) {
755 new_porymap_component_->tiles_png(tiles_workspace_->export_secondary_image(
756 num_tiles_in_primary_.value(), ExportFlipMode::original, ExportTrimMode::trim_trailing_transparent));
757 }
758 else {
759 new_porymap_component_->tiles_png(
760 tiles_workspace_->export_image(ExportFlipMode::original, ExportTrimMode::trim_trailing_transparent));
761 }
762 }
763 else {
764 new_porymap_component_->tiles_png(
765 tiles_workspace_->export_image(ExportFlipMode::original, ExportTrimMode::include_trailing_transparent));
766 }
767
768 // Copy palettes to output
769 if (is_secondary()) {
770 // Primary palette slots
771 if (has_paired_primary()) {
772 for (std::size_t i = 0; i < num_pals_in_primary_.value(); i++) {
773 new_porymap_component_->set_pal(i, paired_primary_->porymap_component().pal_at(i));
774 }
775 }
776 else {
777 // Standalone secondary: zeroed palettes for primary slots
778 for (std::size_t i = 0; i < num_pals_in_primary_.value(); i++) {
779 new_porymap_component_->set_pal(
781 }
782 }
783 // Secondary palettes from packing result
784 for (std::size_t i = num_pals_in_primary_.value(); i < num_pals_total_.value(); i++) {
785 new_porymap_component_->set_pal(i, new_porymap_pals_.at(i));
786 }
787 // Junk/reserved palettes (13-15) from original secondary component
788 for (std::size_t i = num_pals_total_.value(); i < pal::num_pals; i++) {
789 new_porymap_component_->set_pal(i, tileset_.porymap_component().pal_at(i));
790 }
791 }
792 else {
793 for (std::size_t i = 0; i < pal::num_pals; i++) {
794 new_porymap_component_->set_pal(i, new_porymap_pals_.at(i));
795 }
796 }
797
798 // Apply true_color palette encoding to tiles.png if configured
799 if (tiles_pal_mode_ == TilesPalMode::true_color) {
800 pipeline_helper_apply_true_color_to_tiles_png();
801 }
802
803 // Create the full Tileset and return
804 return std::make_unique<Tileset>(
805 tileset_.name(), std::move(new_porytiles_component), std::move(new_porymap_component_));
806}
807
808std::optional<TilemapEntry> CompilerTask::pipeline_helper_try_reuse_porymap_tile(std::size_t tile_index)
809{
810 // Preconditions for non-optimize mode
811 assert_or_panic(tile_index < porytiles_pixel_rgba_.size(), "tile_index out of bounds for porytiles_pixel_rgba_");
813 porymap_pixel_rgba_.size() == porymap_canonical_pixel_rgba_.size(),
814 "porymap_pixel_rgba_.size() != porymap_canonical_pixel_rgba_.size()");
816 porymap_canonical_pixel_rgba_.size() == porymap_tilemap_entries_.size(),
817 "porymap_canonical_pixel_rgba_.size() != porymap_tilemap_entries_.size()");
818
819 if (tile_index >= porymap_pixel_rgba_.size()) {
820 // tile_index is out-of-range to reuse Porymap assets, so just return nullopt
821 return std::nullopt;
822 }
823
824 const auto &porytiles_tile = porytiles_pixel_rgba_[tile_index];
825 const auto &porymap_tile = porymap_pixel_rgba_[tile_index];
826 const auto &canonical_porytiles_tile = porytiles_canonical_pixel_rgba_[tile_index];
827 const auto &canonical_porymap_tile = porymap_canonical_pixel_rgba_[tile_index];
828 const auto &porymap_tilemap_entry = porymap_tilemap_entries_[tile_index];
829
830 // CASE: Exact match - Porytiles tile exactly matches Porymap tile
831 if (porytiles_tile.equals_ignoring_transparency(porymap_tile, extrinsic_transparency_)) {
832 return porymap_tilemap_entry;
833 }
834
835 // CASE: Canonical match - tiles match under flip transformation
836 if (canonical_porytiles_tile.equals_ignoring_transparency(canonical_porymap_tile, extrinsic_transparency_)) {
837 // XOR flip bits to compute transformation from Porytiles orientation to Porymap orientation
838 const bool pt_to_pm_hflip = canonical_porytiles_tile.h_flip() ^ canonical_porymap_tile.h_flip();
839 const bool pt_to_pm_vflip = canonical_porytiles_tile.v_flip() ^ canonical_porymap_tile.v_flip();
840 return TilemapEntry{
841 porymap_tilemap_entry.tile_index(),
842 porymap_tilemap_entry.pal_index(),
843 static_cast<bool>(porymap_tilemap_entry.h_flip() ^ pt_to_pm_hflip),
844 static_cast<bool>(porymap_tilemap_entry.v_flip() ^ pt_to_pm_vflip)};
845 }
846
847 // No match found
848 return std::nullopt;
849}
850
851TileAssignmentResult
852CompilerTask::pipeline_helper_assign_tile_via_pal_match(const PixelTile<Rgba32> &porytiles_tile, std::size_t flat_index)
853{
854 TileAssignmentResult result{};
855
856 /*
857 * Use the packer's authoritative palette assignment when available (optimize mode). This ensures tile sharing
858 * alignment is respected. The packer and alignment system chose specific palettes for each tile, and re-deriving
859 * via match_or_best could pick a different palette that breaks sharing slot alignment.
860 *
861 * Falls back to match_or_best for tiles not in the packer's assignments (e.g., locked/patch modes, or tiles
862 * excluded from packing like animation keyframes).
863 */
864 std::size_t pal_index;
865 if (tile_to_pal_.contains(flat_index)) {
866 pal_index = tile_to_pal_.at(flat_index);
867 }
868 else {
869 std::vector<PaletteMatchResult<Rgba32>> matches =
870 match_or_best(porytiles_tile, new_porymap_pals_, extrinsic_transparency_.value(), 1);
871
872 if (!matches.at(0).is_covered) {
873 result.status = TileAssignmentResult::Status::no_covering_pal;
874 result.match_results = std::move(matches);
875 return result;
876 }
877 pal_index = matches.at(0).pal_index;
878 }
879
880 const auto &matched_pal = new_porymap_pals_.at(pal_index);
881 const auto index_tile = index_tile_from_color_tile(porytiles_tile, matched_pal, extrinsic_transparency_.value());
882 const CanonicalPixelTile canonical_index_tile{index_tile};
883
884 /*
885 * In non-optimize modes with available original tilemap data, only use the animation matcher if the original
886 * tile_index was within a registered animation range. This prevents false positive interception where a static tile
887 * that visually matches an animation keyframe gets incorrectly mapped to the animation tile_index, causing
888 * unintended animation at runtime.
889 */
890 bool should_check_anim_matcher = true;
891 if (tiles_edit_mode_ != ArtifactEditMode::optimize && flat_index < porymap_tilemap_entries_.size()) {
892 const auto original_tile_index = porymap_tilemap_entries_[flat_index].tile_index();
893 should_check_anim_matcher = anim_tile_matcher_.is_in_animation_range(original_tile_index);
894 }
895
896 // Check if tile matches a registered animation keyframe
897 if (should_check_anim_matcher) {
898 if (const auto anim_match = anim_tile_matcher_.find_match(
899 CanonicalPixelTile{porytiles_tile, extrinsic_transparency_.value()}, extrinsic_transparency_.value());
900 anim_match.has_value()) {
901 if (anim_match->is_cross_tileset) {
902 std::vector<std::string> remark_lines;
903 remark_lines.emplace_back(format_.format(
904 "Tile at flat index '{}' matched primary animation '{}' subtile '{}'.",
905 FormatParam{flat_index, Style::bold},
906 FormatParam{anim_match->anim_name, Style::bold},
907 FormatParam{anim_match->keyframe_tile_idx, Style::bold}));
908 diag_.remark("cross-tileset-anim-match", remark_lines);
909 }
910 // Use the animation tile index with palette from anim_pal_indices_ and computed flip bits
911 result.status = TileAssignmentResult::Status::success;
912 result.entry = TilemapEntry{
913 anim_match->tile_index,
914 anim_pal_indices_.at(anim_match->anim_name).at(anim_match->keyframe_tile_idx),
915 anim_match->h_flip,
916 anim_match->v_flip};
917 return result;
918 }
919 }
920
921 /*
922 * Tile found in workspace
923 *
924 * In optimize mode, we use fast O(1) exact index matching because palettes are freshly computed by the palette
925 * packing algorithm, which never produces duplicate colors. In patch/locked modes, we use O(n) color-equivalence
926 * comparison because vanilla palettes may contain duplicate colors at different indices. For example, if palette
927 * slots 7 and 14 both contain RGB(255,0,0), our index_tile_from_color_tile() always picks slot 7 (the first
928 * match), but vanilla workspace tiles might use slot 14. Exact index matching would fail to find the tile, causing
929 * unnecessary tile insertions or "tile not found" errors in locked mode.
930 */
931 const auto maybe_tile_index =
932 (tiles_edit_mode_ == ArtifactEditMode::optimize)
933 ? tiles_workspace_->first_occurrence_of(canonical_index_tile)
934 : tiles_workspace_->first_occurrence_of_by_color(canonical_index_tile, matched_pal);
935
936 if (maybe_tile_index.has_value()) {
937 const auto workspace_tile_index = maybe_tile_index.value();
938
939 /*
940 * Warn if workspace fallthrough resolved to a primary animation range. When cross-tileset linking is
941 * enabled, the RGBA key frame matcher (above) catches tiles that visually match primary key frames.
942 * This branch catches a different case: tiles that don't match the RGBA key frame pixels but produce
943 * identical IndexPixel data after palette mapping (indexed-pixel coincidence). This happens when two
944 * visually distinct RGBA tiles map to the same palette indices. When cross-tileset linking is disabled,
945 * this catches all workspace-level matches to primary animation ranges.
946 */
947 if (is_secondary() && has_paired_primary()) {
948 const auto &primary_porymap_anims = paired_primary_->porymap_component().anims();
949 for (const auto &[anim_name, anim] : primary_porymap_anims) {
950 const std::size_t offset = anim.params().tile_offset();
951 const std::size_t count = anim.params().tile_count();
952 if (workspace_tile_index >= offset && workspace_tile_index < offset + count) {
953 std::vector<std::string> warning_lines;
954 if (cross_tileset_anim_linking_.value()) {
955 warning_lines.emplace_back(format_.format(
956 "Tile at flat index '{}' resolved to primary animation '{}' range via workspace lookup "
957 "(not key frame matching).",
958 FormatParam{flat_index},
959 FormatParam{anim_name, Style::bold}));
960 warning_lines.emplace_back(
961 "The tile may not visually match the key frame but produces identical indexed pixel data.");
962 diag_.warning("cross-tileset-anim-fallthrough", warning_lines);
963 }
964 else {
965 warning_lines.emplace_back(format_.format(
966 "Tile at flat index '{}' resolved to primary animation '{}' range via workspace "
967 "deduplication, despite '{}' being disabled.",
968 FormatParam{flat_index},
969 FormatParam{anim_name, Style::bold},
970 FormatParam{"cross_tileset_anim_linking", Style::bold}));
971 warning_lines.emplace_back("This tile will animate at runtime.");
972 warning_lines.emplace_back(
973 "To suppress, restructure your tile art to avoid matching primary animation pixels, or "
974 "enable cross-tileset linking for explicit control.");
975 diag_.warning("cross-tileset-anim-fallthrough-disabled", warning_lines);
976 }
977 break;
978 }
979 }
980 }
981
982 const auto workspace_tile = tiles_workspace_->tile_at(workspace_tile_index);
983 const bool pt_to_pm_hflip = canonical_index_tile.h_flip() ^ workspace_tile.h_flip();
984 const bool pt_to_pm_vflip = canonical_index_tile.v_flip() ^ workspace_tile.v_flip();
985 result.status = TileAssignmentResult::Status::success;
986 result.entry = TilemapEntry{workspace_tile_index, pal_index, pt_to_pm_hflip, pt_to_pm_vflip};
987 return result;
988 }
989
990 // Tile not found - locked mode cannot insert new tiles
991 if (tiles_edit_mode_ == ArtifactEditMode::locked) {
992 result.status = TileAssignmentResult::Status::tile_not_found;
993 result.index_tile = index_tile;
994 result.pal_index = pal_index;
995 result.matched_pal = matched_pal;
996 return result;
997 }
998
999 // Tile not found - check capacity before inserting
1000 if (tiles_workspace_->at_capacity()) {
1001 result.status = TileAssignmentResult::Status::tile_limit_reached;
1002 return result;
1003 }
1004
1005 // Insert the new tile
1006 const std::size_t inserted_index = tiles_workspace_->insert_tile(canonical_index_tile);
1007 const auto workspace_tile = tiles_workspace_->tile_at(inserted_index);
1008 result.status = TileAssignmentResult::Status::success;
1009 const bool pt_to_pm_hflip = canonical_index_tile.h_flip() ^ workspace_tile.h_flip();
1010 const bool pt_to_pm_vflip = canonical_index_tile.v_flip() ^ workspace_tile.v_flip();
1011 result.entry = TilemapEntry{inserted_index, pal_index, pt_to_pm_hflip, pt_to_pm_vflip};
1012 return result;
1013}
1014
1015ChainableResult<void> CompilerTask::pipeline_helper_run_pal_packing()
1016{
1017 /*
1018 * Create ColorIndexMap from the Porytiles tiles, Porytiles pals, and palette hints. We already validated earlier
1019 * that we don't exceed the global color count limit. So this will panic if there are too many global unique colors.
1020 */
1021 const std::size_t color_count_limit =
1022 is_secondary() ? (num_pals_total_.value() - num_pals_in_primary_.value()) * (pal::max_size - 1)
1023 : num_pals_in_primary_.value() * (pal::max_size - 1);
1025 color_index_map,
1026 pipeline_helper_build_color_index_map(pal_hints_.value(), color_count_limit),
1027 void,
1028 std::format("Failed to build color index map for tileset '{}'.", tileset_.name()));
1029
1030 PT_UNWRAP_TILESET_CONFIG_REF(config_, packing_strategy, tileset_.name(), void);
1031 PT_UNWRAP_TILESET_CONFIG_REF(config_, packing_strategy_params, tileset_.name(), void);
1032 PT_UNWRAP_TILESET_CONFIG_REF(config_, tile_sharing_packing, tileset_.name(), void);
1033 PT_UNWRAP_TILESET_CONFIG_REF(config_, tile_sharing_alignment, tileset_.name(), void);
1034 auto strategy = make_packing_strategy(packing_strategy.value(), packing_strategy_params.value(), diag_);
1035 PalettePacker pal_packer{strategy.get(), &format_, &diag_, &tile_printer_, &pal_printer_};
1036 std::bitset<pal::num_pals> available_pals{0};
1037 if (is_secondary()) {
1038 if (has_paired_primary()) {
1039 // Enable primary palette slots so the packer can assign tiles whose colors are a subset
1040 // of a locked primary palette. Primary palettes are fully locked via prefilled_pals_, so
1041 // the packer cannot add new colors -- it can only assign tiles to them.
1042 for (std::size_t i = 0; i < num_pals_in_primary_; i++) {
1043 available_pals.set(i, true);
1044 }
1045 }
1046 for (std::size_t i = num_pals_in_primary_; i < num_pals_total_; i++) {
1047 available_pals.set(i, true);
1048 }
1049 }
1050 else {
1051 for (std::size_t i = 0; i < num_pals_in_primary_; i++) {
1052 available_pals.set(i, true);
1053 }
1054 }
1055 PackingParams packing_params{};
1056 packing_params.tiles_ = porytiles_pixel_rgba_;
1057 packing_params.anims_ = tileset_.porytiles_component().anims();
1058 packing_params.color_map_ = color_index_map;
1059 packing_params.extrinsic_transparency_ = extrinsic_transparency_.value();
1060 if (is_secondary()) {
1061 std::array<std::optional<Palette<Rgba32, pal::max_size>>, pal::num_pals> prefilled{};
1062 if (has_paired_primary()) {
1063 // Lock primary palettes from the compiled paired primary
1064 for (std::size_t i = 0; i < num_pals_in_primary_.value(); ++i) {
1065 prefilled.at(i) = paired_primary_->porymap_component().pal_at(i);
1066 }
1067 }
1068 // Carry over secondary Porytiles pal overrides (slots >= num_pals_in_primary)
1069 for (std::size_t i = num_pals_in_primary_.value(); i < pal::num_pals; ++i) {
1070 if (tileset_.porytiles_component().pal_at(i).has_value()) {
1071 prefilled.at(i) = tileset_.porytiles_component().pal_at(i).value();
1072 }
1073 }
1074 packing_params.prefilled_pals_ = prefilled;
1075 }
1076 else {
1077 packing_params.prefilled_pals_ = tileset_.porytiles_component().pals();
1078 }
1079 packing_params.hints_ = pal_hints_.value();
1080 packing_params.available_pals_ = available_pals;
1081 packing_params.tile_sharing_packing_ = tile_sharing_packing;
1082 packing_params.tile_sharing_alignment_ = tile_sharing_alignment;
1083
1084 // Reconstruct RGBA tiles from the paired primary's compiled data for cross-tileset shape group analysis
1085 if (is_secondary() && has_paired_primary()) {
1086 const auto &primary_porymap = paired_primary_->porymap_component();
1087 ImageTileizer<IndexPixel> tileizer{};
1089 primary_indexed_tiles,
1090 tileizer.tileize(primary_porymap.tiles_png()),
1091 void,
1092 "Failed to tileize paired primary's tiles.png for cross-tileset shape group analysis.");
1093
1094 /*
1095 * Normalize the paired primary's entries to triple-layer so a flat slot index decodes cleanly via
1096 * metatile::from_tile_index. This absorbs the dual-layer per-LayerType layout variations (normal/covered/split)
1097 * into canonical bottom/middle/top positioning; the inserted transparent entries are skipped by the existing
1098 * tile_index == 0 filter below.
1099 */
1100 LayerModeConverter layer_mode_converter{&format_, &diag_, &tile_printer_, extrinsic_transparency_.value()};
1102 primary_triple_entries,
1103 layer_mode_converter.triple_layerize(primary_porymap),
1104 void,
1105 "Failed to triple-layerize paired primary for cross-tileset shape group analysis.");
1106
1107 // Dedup on (tile_index, pal_index) ignoring flips. Shape group analysis canonicalizes
1108 // orientations, so different flip variants of the same tile produce the same canonical form.
1109 std::set<std::pair<std::size_t, std::size_t>> seen_tile_pal_pairs;
1110
1111 for (std::size_t slot = 0; slot < primary_triple_entries.size(); ++slot) {
1112 const auto &entry = primary_triple_entries.at(slot);
1113 if (entry.tile_index() == 0) {
1114 continue;
1115 }
1116 auto key = std::make_pair(entry.tile_index(), entry.pal_index());
1117 if (seen_tile_pal_pairs.contains(key)) {
1118 continue;
1119 }
1120 seen_tile_pal_pairs.insert(key);
1121
1122 if (entry.tile_index() >= primary_indexed_tiles.size()) {
1123 continue;
1124 }
1125 const auto &index_tile = primary_indexed_tiles.at(entry.tile_index());
1126 auto flipped_tile = index_tile.flip(entry.h_flip(), entry.v_flip());
1127 auto rgba_tile = color_tile_from_index_tile(
1128 flipped_tile, primary_porymap.pal_at(entry.pal_index()), extrinsic_transparency_.value());
1129 if (rgba_tile.is_transparent(extrinsic_transparency_.value())) {
1130 continue;
1131 }
1132 auto [mt_index, layer, subtile] = metatile::from_tile_index(slot);
1133 packing_params.primary_tiles_.emplace_back(
1134 PackingParams::PrimaryTileRef{std::move(rgba_tile), entry.pal_index(), mt_index, layer, subtile});
1135 }
1136 }
1137
1139 pal_packing,
1140 pal_packer.pack_tiles(packing_params),
1141 void,
1142 format_.format("Failed to pack palettes for tileset '{}'.", FormatParam{tileset_.name(), Style::bold}));
1143
1144 tile_to_pal_ = std::move(pal_packing.tile_to_pal_);
1145
1146 for (std::size_t i = 0; i < pal::num_pals; i++) {
1147 if (const auto &maybe_packed_pal = pal_packing.pals_.at(i); maybe_packed_pal.has_value()) {
1148 // Copy over the packed palette
1149 new_porymap_pals_[i] = maybe_packed_pal.value();
1150 }
1151 else if (tileset_.porytiles_component().pal_at(i).has_value()) {
1152 /*
1153 * Out-of-band Porytiles palette: exists but wasn't used in packing (e.g., palette 11.pal in a primary
1154 * tileset). Resolve all wildcards to black and copy it over.
1155 */
1156 const auto &porytiles_pal = tileset_.porytiles_component().pal_at(i).value();
1158
1159 // Handle slot 0: preserve if not wildcard, otherwise use extrinsic transparency
1160 if (!porytiles_pal.is_wildcard(0)) {
1161 resolved_pal.set(0, porytiles_pal.at(0));
1162 }
1163 else {
1164 resolved_pal.set(0, extrinsic_transparency_.value());
1165 }
1166
1167 // Copy non-wildcard slots (wildcards remain as the default black)
1168 for (std::size_t j = 1; j < pal::max_size; ++j) {
1169 if (!porytiles_pal.is_wildcard(j)) {
1170 resolved_pal.set(j, porytiles_pal.at(j));
1171 }
1172 }
1173
1174 new_porymap_pals_[i] = resolved_pal;
1175 }
1176 else {
1177 /*
1178 * Copy remaining secondary palettes from the original component. The "secondary" pals in a primary
1179 * tileset's folder won't be actually loaded by the game engine. Porymap also doesn't show them -- it
1180 * will grab pals from the relevant secondary set folder. However, we copy them here for consistency. If
1181 * for some reason the user had edited them, we don't want to clobber their edits. Porytiles should be
1182 * surgical where possible.
1183 *
1184 * Copy junk pals. 13.pal, 14.pal, 15.pal exist in the tileset but are reserved by the game engine for
1185 * overworld/shop UI. Here we just copy them over as-is. Again, if for some reason the user had edited
1186 * them, let's not clobber anything unnecessarily.
1187 */
1188 new_porymap_pals_[i] = tileset_.porymap_component().pal_at(i);
1189 }
1190 }
1191
1192 return {};
1193}
1194
1195ChainableResult<ColorIndexMap<Rgba32>> CompilerTask::pipeline_helper_build_color_index_map(
1196 const std::vector<PaletteHint> &hints, std::size_t color_count_limit) const
1197{
1198 // Create ColorIndexMap from the Porytiles tiles
1199 ColorIndexMap<Rgba32> color_index_map{};
1200 for (const auto &tile : porytiles_pixel_rgba_) {
1201 color_index_map.add_tile(tile, extrinsic_transparency_.value());
1202 }
1203
1204 // Add Porytiles anims
1205 for (const auto &anim : tileset_.porytiles_component().anims() | std::views::values) {
1206 color_index_map.add_anim(anim, extrinsic_transparency_.value());
1207 }
1208
1209 // Add Porytiles palettes (for secondary, iterate over secondary palette slots)
1210 const std::size_t pal_start = is_secondary() ? num_pals_in_primary_.value() : 0;
1211 const std::size_t pal_end = is_secondary() ? num_pals_total_.value() : num_pals_in_primary_.value();
1212 for (std::size_t pal_index = pal_start; pal_index < pal_end; ++pal_index) {
1213 const auto &maybe_porytiles_pal = tileset_.porytiles_component().pals().at(pal_index);
1214 if (!maybe_porytiles_pal.has_value()) {
1215 continue;
1216 }
1217 color_index_map.add_pal(maybe_porytiles_pal.value(), extrinsic_transparency_.value());
1218 }
1219
1220 // Add palette hints
1221 for (const auto &hint : hints) {
1222 color_index_map.add_pal(hint.pal(), extrinsic_transparency_.value());
1223 }
1224
1225 // Check color count one more time, we validated this earlier and provided granular feedback to user
1226 if (color_index_map.size() > color_count_limit) {
1227 panic(
1228 "color_index_map.size() > count_limit - this should have already been validated by "
1229 "pipeline_step_validate_input");
1230 }
1231
1232 /*
1233 * For secondary compilation, add primary palette colors to the map. The packer needs these to build ColorSets for
1234 * locked primary palettes, enabling secondary tiles that only use primary colors to be correctly assigned to a
1235 * primary palette. These colors don't count against the secondary color budget, so they're added after the limit
1236 * check.
1237 */
1238 if (is_secondary() && has_paired_primary()) {
1239 for (std::size_t i = 0; i < num_pals_in_primary_.value(); ++i) {
1240 const auto &primary_pal = paired_primary_->porymap_component().pal_at(i);
1241 color_index_map.add_pal(primary_pal, extrinsic_transparency_.value());
1242 }
1243 }
1244
1245 return color_index_map;
1246}
1247
1249CompilerTask::pipeline_helper_build_keyframe_data(const std::string &anim_name, const Animation<Rgba32> &anim) const
1250{
1251 const AnimFrame<Rgba32> &composite_frame = anim.composite_frame(extrinsic_transparency_);
1252 const std::size_t tile_count = composite_frame.tiles().size();
1253
1254 AnimKeyframeData result;
1255 result.tiles.reserve(tile_count);
1256 result.palettes.reserve(tile_count);
1257
1258 /*
1259 * For automatic/hybrid mode, we use the key frame tiles. For manual mode (no key frame),
1260 * we use the first regular frame's tiles as the representative tiles to place in tiles.png.
1261 */
1262 const AnimFrame<Rgba32> &representative_frame =
1263 anim.has_key_frame() ? anim.key_frame() : anim.frames().begin()->second;
1264
1265 for (std::size_t tile_idx = 0; tile_idx < tile_count; ++tile_idx) {
1266 const PixelTile<Rgba32> &composite_rgba_tile = composite_frame.tile_at(tile_idx);
1267 const PixelTile<Rgba32> &representative_tile = representative_frame.tile_at(tile_idx);
1268
1269 /*
1270 * Transparent representative tiles are valid for animations without a key frame. They just produce a
1271 * transparent IndexPixel tile with palette index 0. For key frame animations, validate_anim_frames() catches
1272 * transparent tiles before we get here.
1273 */
1274 if (representative_tile.is_transparent(extrinsic_transparency_.value())) {
1275 PixelTile<IndexPixel> transparent_tile{IndexPixel{0}};
1276 result.tiles.emplace_back(transparent_tile);
1277 result.pal_indices.push_back(0);
1278 result.palettes.push_back(&new_porymap_pals_.at(0));
1279 continue;
1280 }
1281
1282 /*
1283 * Match tile to palette using composite frame to guarantee correct palette selection. As we have seen, some
1284 * animations, like FireRed General's water_current_landwatersedge, have animated tiles that different palettes
1285 * in different tilemap entries. Here, we're only selecting the first matching pal. It will be up to the user to
1286 * ensure that the other pals are aligned such that the IndexTile we generate from this step will work for every
1287 * palette the animation uses.
1288 *
1289 * Eventually, when we support tileset.tiles.sharing configuration, we might want to make this approach more
1290 * sophisticated.
1291 */
1292 std::vector<PaletteMatchResult<Rgba32>> matches =
1293 match_or_best(composite_rgba_tile, new_porymap_pals_, extrinsic_transparency_.value(), 1);
1294
1295 if (!matches.at(0).is_covered) {
1296 std::vector<std::string> err_lines;
1297 std::vector<std::vector<FormatParam>> err_params;
1298
1299 // Header line
1300 err_lines.emplace_back("Animation '{}' composite subtile '{}': no matching palette found.");
1301 err_params.push_back({FormatParam{anim_name, Style::bold}, FormatParam{tile_idx, Style::bold}});
1302
1303 // Closest N match(es) with covered/missing colors
1304 err_lines.emplace_back();
1305 err_params.emplace_back();
1306 err_lines.emplace_back("Closest N match(es) with covered colors highlighted:");
1307 err_lines.emplace_back();
1308 err_params.emplace_back();
1309 err_params.emplace_back();
1310 int match_idx = 0;
1311 for (const auto &match : matches) {
1312 if (match_idx != 0) {
1313 err_lines.emplace_back();
1314 err_params.emplace_back();
1315 }
1316 err_lines.emplace_back("Palette match candidate: {}");
1317 err_params.push_back({FormatParam{pal_filename(match.pal_index), Style::bold}});
1318 for (const auto &line : pal_printer_.print_rgba_palette_covered_missing(
1319 new_porymap_pals_.at(match.pal_index), match.covered_colors, match.missing_colors)) {
1320 err_lines.push_back(line);
1321 err_params.emplace_back();
1322 }
1323 match_idx++;
1324 }
1325
1326 return FormattableError{std::move(err_lines), std::move(err_params)};
1327 }
1328
1329 // Convert key frame tile to IndexPixel using matched palette
1330 const std::size_t pal_index = matches.at(0).pal_index;
1331 const auto &matched_pal = new_porymap_pals_.at(pal_index);
1332 const PixelTile<IndexPixel> indexed_key_frame_tile =
1333 index_tile_from_color_tile(representative_tile, matched_pal, extrinsic_transparency_.value());
1334
1335 result.tiles.emplace_back(indexed_key_frame_tile);
1336 result.pal_indices.push_back(pal_index);
1337 // We'll only actually use this vector in patch mode, but compute anyway to simplify code paths
1338 result.palettes.push_back(&matched_pal);
1339 }
1340
1341 return result;
1342}
1343
1344ChainableResult<void> CompilerTask::pipeline_helper_register_animations()
1345{
1346 /*
1347 * This function has two primary responsibilities. For each anim:
1348 *
1349 * 1. Place the anim's key frame tiles into tiles.png at computed offsets
1350 * 2. Register each animation and save the computed offsets
1351 *
1352 * The strategy differs by mode:
1353 * - optimize: Reserve slots at the start, place keyframes in reserved region
1354 * - patch: Try to reuse existing keyframes, else find contiguous free space
1355 * - locked: Keyframes must already exist in tiles.png
1356 */
1357 const auto &anims = tileset_.porytiles_component().anims();
1358
1359 if (!anims.empty()) {
1360
1361 if (tiles_edit_mode_ == ArtifactEditMode::optimize) {
1362 std::size_t total_keyframe_tiles = 0;
1363 for (const auto &anim : anims | std::views::values) {
1364 if (anim.has_key_frame()) {
1365 total_keyframe_tiles += anim.key_frame().tiles().size();
1366 }
1367 else if (anim.has_frames()) {
1368 total_keyframe_tiles += anim.frames().begin()->second.tiles().size();
1369 }
1370 }
1371 const std::size_t anim_start = is_secondary() ? (num_tiles_in_primary_.value() + 1) : 1;
1372 tiles_workspace_->reserve_anim_slots(total_keyframe_tiles, anim_start);
1373 }
1374
1375 std::map<std::string, std::size_t> anim_offsets;
1376 std::map<std::string, std::vector<std::size_t>> anim_pal_indices;
1377 std::size_t current_offset = tiles_workspace_->anim_start_offset();
1378
1379 const auto &per_anim_overrides = per_anim_overrides_.value();
1380
1381 for (const auto &[anim_name, anim] : anims) {
1382 if (!anim.has_frames()) {
1383 panic("anim '" + anim_name + "' has no frames");
1384 }
1385
1386 // Build keyframe data (common to all modes, needed for pal_indices even if we skip tile placement)
1387 PT_TRY_ASSIGN_PASS_ERR(keyframe_data, pipeline_helper_build_keyframe_data(anim_name, anim), void);
1388
1389 const std::size_t tile_count = keyframe_data.tiles.size();
1390 anim_pal_indices[anim_name] = keyframe_data.pal_indices;
1391 std::size_t offset{};
1392
1393 // Resolve effective FrameLinking for this animation
1394 const ConfigValue<FrameLinking> effective_linking =
1395 (per_anim_overrides.contains(anim_name) && per_anim_overrides.at(anim_name).linking.has_value())
1396 ? per_anim_overrides_.derive(per_anim_overrides.at(anim_name).linking)
1397 : global_frame_linking_;
1398
1399 if (effective_linking == FrameLinking::manual && tiles_edit_mode_ != ArtifactEditMode::optimize) {
1400 /*
1401 * Manual frame linking in patch/locked mode: use the tile_offset from anim.json directly.
1402 * Don't search tiles.png. The keyframes may not be findable via color matching. Whatever
1403 * is already at that offset in tiles.png will be dynamically overwritten by the game's
1404 * animation DMA code at runtime anyway.
1405 */
1406 const std::size_t json_offset = anim.params().tile_offset();
1407 if (json_offset == 0) {
1408 return FormattableError{
1409 "Animation '{}' uses manual frame linking in '{}' mode but has no tile_offset in anim.json.",
1410 FormatParam{anim_name, Style::bold},
1411 FormatParam{to_string(tiles_edit_mode_.value()), Style::bold}};
1412 }
1413 if (json_offset + tile_count > tiles_workspace_->capacity()) {
1414 return FormattableError{
1415 "Animation '{}' tile_offset '{}' + tile_count '{}' exceeds tiles.png capacity '{}'.",
1416 FormatParam{anim_name, Style::bold},
1417 FormatParam{json_offset, Style::bold},
1418 FormatParam{tile_count, Style::bold},
1419 FormatParam{tiles_workspace_->capacity(), Style::bold}};
1420 }
1421 offset = json_offset;
1422 }
1423 else {
1424 // Automatic mode (all edit modes) OR manual mode with optimize
1425 if (tiles_edit_mode_ == ArtifactEditMode::optimize) {
1426 offset = current_offset;
1427 for (std::size_t i = 0; i < tile_count; ++i) {
1428 const std::size_t reserved_index = current_offset - tiles_workspace_->anim_start_offset();
1429 tiles_workspace_->place_anim_tile(reserved_index, keyframe_data.tiles[i]);
1430 ++current_offset;
1431 }
1432 }
1433 else if (tiles_edit_mode_ == ArtifactEditMode::patch) {
1434 // Try to find existing contiguous keyframe sequence using color-equivalence comparison
1435 if (const auto existing_offset = tiles_workspace_->find_existing_contiguous_tiles_by_color(
1436 keyframe_data.tiles, keyframe_data.palettes);
1437 existing_offset.has_value()) {
1438 offset = existing_offset.value();
1439 }
1440 // If full sequence not found, find sufficient contiguous free space to insert
1441 else if (
1442 const auto free_offset = tiles_workspace_->find_contiguous_transparent_slots(tile_count);
1443 free_offset.has_value()) {
1444 tiles_workspace_->place_tiles_at(free_offset.value(), keyframe_data.tiles);
1445 offset = free_offset.value();
1446 }
1447 else {
1448 return FormattableError{
1449 "Animation '{}' requires {} contiguous tiles but no sufficient space found.",
1450 FormatParam{anim_name, Style::bold},
1451 FormatParam{tile_count, Style::bold}};
1452 }
1453 }
1454 else if (tiles_edit_mode_ == ArtifactEditMode::locked) {
1455 // In locked mode, keyframes must already exist contiguously
1456 // Use color-equivalence comparison to handle duplicate palette colors (same fix as patch mode)
1457 const auto existing_offset = tiles_workspace_->find_existing_contiguous_tiles_by_color(
1458 keyframe_data.tiles, keyframe_data.palettes);
1459 if (existing_offset.has_value()) {
1460 offset = existing_offset.value();
1461 }
1462 else {
1463 std::vector<std::string> err_msg{};
1464 err_msg.emplace_back(format_.format(
1465 "Animation '{}' keyframes not found in existing tiles.png.",
1466 FormatParam{anim_name, Style::bold}));
1467 err_msg.emplace_back(format_.format(
1468 "Cannot proceed due to '{}' setting '{}'.",
1469 FormatParam{"Tiles Edit Mode", Style::bold},
1470 FormatParam{"locked", Style::bold}));
1471 err_msg.append_range(format_config_note_with_separator(format_, tiles_edit_mode_));
1472 return FormattableError{err_msg};
1473 }
1474 }
1475 else {
1476 panic("unexpected tiles_edit_mode");
1477 }
1478 }
1479
1480 anim_offsets[anim_name] = offset;
1481 }
1482
1483 for (const auto &[anim_name, anim] : anims) {
1484 anim_pal_indices_[anim_name] = anim_pal_indices.at(anim_name);
1485 anim_tile_matcher_.register_animation(
1486 anim_name, anim, anim_offsets.at(anim_name), extrinsic_transparency_.value());
1487 }
1488
1489 } // if (!anims.empty())
1490
1491 // Register primary animations for cross-tileset linking (secondary only)
1492 if (is_secondary() && has_paired_primary() && cross_tileset_anim_linking_.value()) {
1493 const auto &primary_porytiles_anims = paired_primary_->porytiles_component().anims();
1494 const auto &primary_porymap_anims = paired_primary_->porymap_component().anims();
1495
1496 /*
1497 * Build a lookup from tile_index to pal_index using the primary's compiled metatile data.
1498 * This is the authoritative source for which palette each primary tile was compiled against.
1499 * If multiple metatile entries reference the same tile with different palettes, the first
1500 * entry wins (consistent with the first-match convention used in
1501 * pipeline_helper_build_keyframe_data).
1502 */
1503 std::map<std::size_t, std::size_t> primary_tile_pal_map;
1504 for (const auto &entry : paired_primary_->porymap_component().metatiles_bin()) {
1505 if (entry.tile_index() == 0) {
1506 continue;
1507 }
1508 primary_tile_pal_map.try_emplace(entry.tile_index(), entry.pal_index());
1509 }
1510
1511 // Build primary palette vector once for RGBA fallback matching
1512 std::vector<Palette<Rgba32, pal::max_size>> primary_palettes;
1513 primary_palettes.reserve(num_pals_in_primary_.value());
1514 for (std::size_t i = 0; i < num_pals_in_primary_.value(); ++i) {
1515 primary_palettes.push_back(paired_primary_->porymap_component().pal_at(i));
1516 }
1517
1518 // Check for stale compiled data: animations in porymap but removed from porytiles source
1519 for (const auto &prim_anim_name : primary_porymap_anims | std::views::keys) {
1520 if (!primary_porytiles_anims.contains(prim_anim_name)) {
1521 return FormattableError{std::vector<std::string>{
1522 format_.format(
1523 "Primary animation '{}' exists in compiled Porymap data but not in Porytiles source.",
1524 FormatParam{prim_anim_name, Style::bold}),
1525 "The paired primary tileset has uncompiled changes. Recompile it before compiling this "
1526 "secondary."}};
1527 }
1528 }
1529
1530 for (const auto &[prim_anim_name, prim_anim] : primary_porytiles_anims) {
1531 if (!primary_porymap_anims.contains(prim_anim_name)) {
1532 return FormattableError{std::vector<std::string>{
1533 format_.format(
1534 "Primary animation '{}' exists in Porytiles source but not in compiled Porymap data.",
1535 FormatParam{prim_anim_name, Style::bold}),
1536 "The paired primary tileset has uncompiled changes. Recompile it before compiling this "
1537 "secondary."}};
1538 }
1539 if (!prim_anim.has_key_frame()) {
1540 /*
1541 * Manual-mode primary animations have no key frame for RGBA matching. They are still present in the
1542 * workspace and can be linked via fallthrough (which emits its own diagnostic).
1543 */
1544 std::vector<std::string> remark_lines;
1545 remark_lines.emplace_back(format_.format(
1546 "Primary animation '{}' has no key frame (likely manual frame linking).",
1547 FormatParam{prim_anim_name, Style::bold}));
1548 remark_lines.emplace_back("Cross-tileset key frame matching is not possible for this animation.");
1549 diag_.remark("cross-tileset-anim-skip-no-keyframe", remark_lines);
1550 continue;
1551 }
1552
1553 const auto &prim_porymap_anim = primary_porymap_anims.at(prim_anim_name);
1554 const std::size_t prim_tile_offset = prim_porymap_anim.params().tile_offset();
1555
1556 const std::size_t prim_tile_count = prim_anim.key_frame().tile_count();
1557
1558 /*
1559 * Collision detection is performed before palette index resolution so that the error path does not waste
1560 * work on palette lookups. If a user has both a collision and an unreferenced subtile, the collision wins:
1561 * collisions indicate an art-side conflict between the primary and secondary that must be resolved before
1562 * anything else, while an unreferenced subtile is a data-layout issue downstream of art choices.
1563 *
1564 * The two loops are independent. Collision detection only reads anim_tile_matcher_;
1565 * the palette lookup only reads primary_tile_pal_map.
1566 *
1567 * Check for cross-tileset key frame collisions. At this point only secondary animations have been
1568 * registered in the matcher, so any match is a secondary collision. Each side of the comparison uses its
1569 * own ET: primary subtiles are classified under the paired primary's ET and the canonical form is built
1570 * under that ET, while the matcher's internal comparator classifies the already-registered secondary
1571 * entries under the secondary's ET. This is what lets the comparator find a collision across mismatched-ET
1572 * inputs.
1573 */
1574 for (std::size_t i = 0; i < prim_tile_count; ++i) {
1575 if (prim_anim.key_frame().tile_at(i).is_transparent(paired_primary_extrinsic_transparency_.value())) {
1576 continue;
1577 }
1579 prim_anim.key_frame().tile_at(i), paired_primary_extrinsic_transparency_.value()};
1580 auto match = anim_tile_matcher_.find_match(canonical, paired_primary_extrinsic_transparency_.value());
1581 if (match.has_value() && !match->is_cross_tileset) {
1582 return FormattableError{std::vector<std::string>{
1583 format_.format(
1584 "Primary animation '{}' subtile '{}' has identical RGBA data to secondary animation '{}' "
1585 "subtile '{}'.",
1586 FormatParam{prim_anim_name, Style::bold},
1587 FormatParam{i},
1588 FormatParam{match->anim_name, Style::bold},
1589 FormatParam{match->keyframe_tile_idx}),
1590 "Cross-tileset animation linking requires unique key frame subtiles across primary and "
1591 "secondary.",
1592 "Fix the secondary (or primary) animation art so key frame subtiles are visually distinct."}};
1593 }
1594 }
1595
1596 /*
1597 * Palette resolution cascade for cross-tileset subtiles:
1598 * 1. Try metatile lookup (authoritative when subtile is referenced in primary metatiles)
1599 * 2. Fall back to RGBA matching against primary palettes (for subtiles only referenced cross-tileset)
1600 * Use the composite frame for RGBA matching — it covers all colors across all animation frames.
1601 */
1602 const AnimFrame<Rgba32> composite =
1603 prim_anim.composite_frame(paired_primary_extrinsic_transparency_.value());
1604
1605 std::vector<std::size_t> subtile_pal_indices;
1606 subtile_pal_indices.reserve(prim_tile_count);
1607 for (std::size_t i = 0; i < prim_tile_count; ++i) {
1608 const std::size_t abs_tile_index = prim_tile_offset + i;
1609
1610 if (prim_anim.key_frame().tile_at(i).is_transparent(paired_primary_extrinsic_transparency_.value())) {
1611 // Transparent subtiles are skipped during register_animation. Push a dummy value.
1612 subtile_pal_indices.push_back(0);
1613 continue;
1614 }
1615
1616 if (primary_tile_pal_map.contains(abs_tile_index)) {
1617 subtile_pal_indices.push_back(primary_tile_pal_map.at(abs_tile_index));
1618 }
1619 else {
1620 /*
1621 * Subtile not referenced in any primary metatile. Fall back to RGBA matching the composite tile
1622 * against the primary's compiled palettes.
1623 */
1624 auto matches = match_or_best(
1625 composite.tile_at(i), primary_palettes, paired_primary_extrinsic_transparency_.value(), 1);
1626 if (matches.at(0).is_covered) {
1627 subtile_pal_indices.push_back(matches.at(0).pal_index);
1628 std::vector<std::string> remark_lines;
1629 remark_lines.emplace_back(format_.format(
1630 "Primary animation '{}' subtile '{}' (tile_index='{}') resolved via RGBA palette fallback "
1631 "to palette '{}'.",
1632 FormatParam{prim_anim_name, Style::bold},
1633 FormatParam{i, Style::bold},
1634 FormatParam{abs_tile_index, Style::bold},
1635 FormatParam{matches.at(0).pal_index, Style::bold}));
1636 remark_lines.emplace_back(
1637 "Subtile is not referenced by any primary metatile but its colors match a primary "
1638 "palette.");
1639 diag_.remark("cross-tileset-anim-rgba-fallback", remark_lines);
1640 }
1641 else {
1642 return FormattableError{std::vector<std::string>{
1643 format_.format(
1644 "Primary animation '{}' subtile '{}' (tile_index='{}') is not referenced by any "
1645 "primary "
1646 "metatile entry and its colors do not fully match any primary palette.",
1647 FormatParam{prim_anim_name, Style::bold},
1648 FormatParam{i},
1649 FormatParam{abs_tile_index}),
1650 "Cannot determine the correct palette index for cross-tileset linking.",
1651 "Recompile the primary tileset, or verify that all primary animation subtiles",
1652 "are used in at least one primary metatile."}};
1653 }
1654 }
1655 }
1656
1657 anim_pal_indices_[prim_anim_name] = subtile_pal_indices;
1658 anim_tile_matcher_.register_animation(
1659 prim_anim_name,
1660 prim_anim,
1661 prim_tile_offset,
1662 paired_primary_extrinsic_transparency_.value(),
1663 /*is_cross_tileset=*/true);
1664 }
1665 }
1666
1667 return {};
1668}
1669
1670ChainableResult<void> CompilerTask::pipeline_helper_validate_primary_anim_subtile_coverage() const
1671{
1672 /*
1673 * Walk each primary animation's key frame subtiles. For every non-transparent subtile, verify its absolute tile
1674 * index appears in at least one metatile entry of this primary's tilemap entries. Unreferenced subtiles are not
1675 * fatal. Paired secondary compiles can resolve their palette via RGBA fallback matching. However, they are worth
1676 * warning about since explicit metatile references are the preferred palette resolution path.
1677 *
1678 * Animations without a key frame (manual frame linking, no RGBA reference) are skipped: they have no palette to
1679 * resolve via metatile lookup.
1680 */
1681 const auto &anims = tileset_.porytiles_component().anims();
1682 if (anims.empty()) {
1683 return {};
1684 }
1685
1686 /*
1687 * Collect tile indices referenced by any metatile entry in this primary's compiled tilemap. Tile 0 is the reserved
1688 * transparent tile and is excluded (it carries no palette information).
1689 */
1690 std::unordered_set<std::size_t> referenced_tile_indices;
1691 for (const auto &entry : new_porymap_component_->metatiles_bin()) {
1692 if (entry.tile_index() == 0) {
1693 continue;
1694 }
1695 referenced_tile_indices.insert(entry.tile_index());
1696 }
1697
1698 for (const auto &[anim_name, anim] : anims) {
1699 if (!anim.has_key_frame()) {
1700 continue;
1701 }
1702
1703 auto maybe_tile_offset = anim_tile_matcher_.tile_offset_for(anim_name);
1704 if (!maybe_tile_offset.has_value()) {
1705 panic("animation '" + anim_name + "' not registered in anim_tile_matcher_");
1706 }
1707 const std::size_t tile_offset = maybe_tile_offset.value();
1708 const std::size_t tile_count = anim.key_frame().tile_count();
1709
1710 for (std::size_t i = 0; i < tile_count; ++i) {
1711 if (anim.key_frame().tile_at(i).is_transparent(extrinsic_transparency_.value())) {
1712 continue;
1713 }
1714 const std::size_t abs_tile_index = tile_offset + i;
1715 if (!referenced_tile_indices.contains(abs_tile_index)) {
1716 std::vector<std::string> warn_lines;
1717 warn_lines.emplace_back(format_.format(
1718 "Primary animation '{}' subtile '{}' (tile_index='{}') is not referenced by any metatile:",
1719 FormatParam{anim_name, Style::bold},
1720 FormatParam{i, Style::bold},
1721 FormatParam{abs_tile_index, Style::bold}));
1722 warn_lines.append_range(
1723 tile_printer_.print_tile(anim.key_frame().tile_at(i), extrinsic_transparency_.value()));
1724 warn_lines.emplace_back(
1725 "Palette assignment for this subtile will use RGBA fallback matching during secondary "
1726 "compilation.");
1727
1728 diag_.warning("primary-anim-unreferenced-subtile", warn_lines);
1729 }
1730 }
1731 }
1732
1733 return {};
1734}
1735
1736void CompilerTask::pipeline_helper_compile_animations()
1737{
1738 const auto &source_anims = tileset_.porytiles_component().anims();
1739
1740 // Early exit if no animations
1741 if (source_anims.empty()) {
1742 return;
1743 }
1744
1745 for (const auto &[anim_name, source_anim] : source_anims) {
1746 // 1. Get the computed tile offset from matcher
1747 auto maybe_tile_offset = anim_tile_matcher_.tile_offset_for(anim_name);
1748 if (!maybe_tile_offset.has_value()) {
1749 panic("animation '" + anim_name + "' not registered in anim_tile_matcher_");
1750 }
1751 const std::size_t tile_offset = maybe_tile_offset.value();
1752
1753 // 2. Compute composite frame for per-subtile palette selection
1754 const AnimFrame<Rgba32> composite = source_anim.composite_frame(extrinsic_transparency_.value());
1755 const std::size_t tile_count = composite.tile_count();
1756
1757 // 3. Build per-subtile palette indices (same logic as registration step)
1758 std::vector<std::size_t> subtile_pal_indices;
1759 subtile_pal_indices.reserve(tile_count);
1760
1761 for (std::size_t tile_idx = 0; tile_idx < tile_count; ++tile_idx) {
1762 const PixelTile<Rgba32> &composite_tile = composite.tile_at(tile_idx);
1763
1764 std::vector<PaletteMatchResult<Rgba32>> matches =
1765 match_or_best(composite_tile, new_porymap_pals_, extrinsic_transparency_.value(), 1);
1766
1767 if (!matches.at(0).is_covered) {
1768 panic(
1769 "animation '" + anim_name + "' subtile " + std::to_string(tile_idx) +
1770 " has no covering palette during compilation");
1771 }
1772
1773 subtile_pal_indices.push_back(matches.at(0).pal_index);
1774 }
1775
1776 // 4. Determine palette for PNG display and warn if multiple palettes are used
1777 const std::size_t frame_pal_index = subtile_pal_indices.at(0);
1778 const bool uses_multiple_palettes =
1779 !std::ranges::all_of(subtile_pal_indices, [&](std::size_t idx) { return idx == frame_pal_index; });
1780
1781 if (uses_multiple_palettes) {
1782 std::vector<std::string> warning_lines;
1783 warning_lines.emplace_back(format_.format(
1784 "Animation '{}' uses multiple palettes across subtiles.", FormatParam{anim_name, Style::bold}));
1785 warning_lines.emplace_back(format_.format(
1786 "Porymap-component frame PNGs will be saved using palette '{}' for display purposes.",
1787 FormatParam{pal_filename(frame_pal_index), Style::bold}));
1788 diag_.warning("multi-palette-animation", warning_lines);
1789 }
1790
1791 // Build a dynamic palette for embedding in the AnimFrame
1792 const auto &fixed_pal = new_porymap_pals_.at(frame_pal_index);
1793 Palette<Rgba32> anim_palette{};
1794 for (std::size_t i = 0; i < fixed_pal.size(); ++i) {
1795 if (fixed_pal.is_wildcard(i)) {
1796 panic("Porymap pal '" + std::to_string(frame_pal_index) + "' has illegal wildcard");
1797 }
1798 anim_palette.add(fixed_pal.at(i));
1799 }
1800
1801 // 5. Convert regular frames (key frame not needed in compiled format)
1802 Animation<IndexPixel> compiled_anim{anim_name};
1803
1804 for (const auto &[frame_name, source_frame] : source_anim.frames()) {
1805 std::vector<PixelTile<IndexPixel>> frame_index_tiles;
1806 frame_index_tiles.reserve(tile_count);
1807
1808 for (std::size_t tile_idx = 0; tile_idx < tile_count; ++tile_idx) {
1809 const PixelTile<Rgba32> &rgba_tile = source_frame.tile_at(tile_idx);
1810 const auto &pal = new_porymap_pals_.at(subtile_pal_indices[tile_idx]);
1811
1812 frame_index_tiles.push_back(
1813 index_tile_from_color_tile(rgba_tile, pal, extrinsic_transparency_.value()));
1814 }
1815
1816 AnimFrame frame{frame_name, std::move(frame_index_tiles)};
1817 frame.palette(anim_palette);
1818 compiled_anim.put_frame(frame_name, std::move(frame));
1819 }
1820
1821 // 6. Set params with updated tile_offset/tile_count
1822 AnimParams params = source_anim.params();
1823 const std::size_t local_offset = is_secondary() ? tile_offset - num_tiles_in_primary_.value() : tile_offset;
1824 params.tile_offset(local_offset);
1825 params.tile_count(tile_count);
1826 compiled_anim.params(std::move(params));
1827
1828 // 7. Add to output component (key_frame left as std::nullopt)
1829 new_porymap_component_->add_anim(std::move(compiled_anim));
1830 }
1831}
1832
1833void CompilerTask::pipeline_helper_apply_manual_overrides()
1834{
1835 const auto &source_anims = tileset_.porytiles_component().anims();
1836 if (source_anims.empty()) {
1837 return;
1838 }
1839
1840 const auto &per_anim_overrides = per_anim_overrides_.value();
1841
1842 for (const auto &[anim_name, source_anim] : source_anims) {
1843 // Resolve effective FrameLinking for this animation
1844 const ConfigValue<FrameLinking> effective_linking =
1845 (per_anim_overrides.contains(anim_name) && per_anim_overrides.at(anim_name).linking.has_value())
1846 ? per_anim_overrides_.derive(per_anim_overrides.at(anim_name).linking)
1847 : global_frame_linking_;
1848
1849 const auto &overrides = source_anim.params().overrides();
1850
1851 switch (effective_linking) {
1852 case FrameLinking::automatic: {
1853 if (!overrides.empty()) {
1854 std::vector<std::string> warning_lines;
1855 warning_lines.emplace_back(format_.format(
1856 "Animation '{}' has frame_linking 'automatic' but overrides are present in anim.json.",
1857 FormatParam{anim_name, Style::bold}));
1858 warning_lines.emplace_back("The overrides will be ignored.");
1859 diag_.warning("automatic-mode-overrides-ignored", warning_lines);
1860 }
1861 break;
1862 }
1863
1864 case FrameLinking::manual: {
1865 if (overrides.empty()) {
1866 std::vector<std::string> warning_lines;
1867 warning_lines.emplace_back(format_.format(
1868 "Animation '{}' has frame_linking '{}' but no overrides are present in anim.json.",
1869 FormatParam{anim_name, Style::bold},
1870 FormatParam{"manual", Style::bold}));
1871 warning_lines.emplace_back("Animation tiles will not be linked to any metatiles.");
1872 diag_.warning("manual-no-overrides", warning_lines);
1873 break;
1874 }
1875
1876 // Get the tile_offset for this animation from the matcher
1877 auto maybe_tile_offset = anim_tile_matcher_.tile_offset_for(anim_name);
1878 if (!maybe_tile_offset.has_value()) {
1879 panic("animation '" + anim_name + "' not registered in anim_tile_matcher_");
1880 }
1881 const std::size_t tile_offset = maybe_tile_offset.value();
1882
1883 // Apply each override entry to metatiles_bin
1884 auto &metatiles_bin = new_porymap_component_->metatiles_bin();
1885 for (const auto &entry : overrides) {
1886 const std::size_t bin_index =
1887 entry.metatile_id * metatile::entries_per_metatile_triple +
1888 static_cast<std::size_t>(entry.layer) * metatile::tiles_per_metatile_layer +
1889 static_cast<std::size_t>(entry.subtile);
1890
1891 if (bin_index >= metatiles_bin.size()) {
1892 panic(
1893 "animation '" + anim_name + "' override references metatile_id " +
1894 std::to_string(entry.metatile_id) + " which is out of range");
1895 }
1896
1897 const std::size_t absolute_tile = tile_offset + entry.frame_subtile;
1898 metatiles_bin[bin_index] = TilemapEntry{absolute_tile, entry.pal_index, entry.h_flip, entry.v_flip};
1899 }
1900 break;
1901 }
1902
1903 case FrameLinking::hybrid: {
1904 std::vector<std::string> err_lines;
1905 err_lines.emplace_back(format_.format(
1906 "Hybrid frame linking is not yet implemented (animation '{}').", FormatParam{anim_name, Style::bold}));
1907 err_lines.emplace_back("Use 'automatic' or 'manual' frame linking until hybrid support.");
1908 err_lines.append_range(format_config_note_with_separator(format_, effective_linking));
1909 diag_.error("hybrid-frame-linking-not-implemented", err_lines);
1910 break;
1911 }
1912
1913 default:
1914 panic("unhandled value for FrameLinking");
1915 }
1916 }
1917
1918 // Apply primary animation reference overrides (secondary tilesets only)
1919 const auto &primary_refs = tileset_.porytiles_component().primary_anim_overrides();
1920
1921 if (!primary_refs.empty() && !is_secondary()) {
1922 std::vector<std::string> err_lines;
1923 err_lines.emplace_back(format_.format(
1924 "Primary tilesets cannot have '{}' in anim.json.", FormatParam{"primary_references", Style::bold}));
1925 err_lines.emplace_back("Only secondary tilesets may reference primary animation tiles.");
1926 diag_.error("primary-references-on-primary", err_lines);
1927 return;
1928 }
1929
1930 if (!primary_refs.empty() && is_secondary()) {
1931 if (!has_paired_primary()) {
1932 std::vector<std::string> err_lines;
1933 err_lines.emplace_back(format_.format(
1934 "The '{}' section requires a paired primary tileset (pairing mode must not be off).",
1935 FormatParam{"primary_references", Style::bold}));
1936 diag_.error("primary-references-no-paired-primary", err_lines);
1937 return;
1938 }
1939
1940 const auto &primary_anims = paired_primary_->porymap_component().anims();
1941 auto &metatiles_bin = new_porymap_component_->metatiles_bin();
1942
1943 for (const auto &[prim_anim_name, entries] : primary_refs) {
1944 if (!primary_anims.contains(prim_anim_name)) {
1945 std::vector<std::string> err_lines;
1946 err_lines.emplace_back(format_.format(
1947 "Primary animation '{}' referenced in '{}' was not found in the paired primary tileset.",
1948 FormatParam{prim_anim_name, Style::bold},
1949 FormatParam{"primary_references", Style::bold}));
1950 diag_.error("primary-references-anim-not-found", err_lines);
1951 continue;
1952 }
1953
1954 const auto &prim_anim = primary_anims.at(prim_anim_name);
1955 const std::size_t prim_tile_offset = prim_anim.params().tile_offset();
1956 const std::size_t prim_tile_count = prim_anim.params().tile_count();
1957
1958 for (const auto &entry : entries) {
1959 if (entry.frame_subtile >= prim_tile_count) {
1960 std::vector<std::string> err_lines;
1961 err_lines.emplace_back(format_.format(
1962 "Primary reference '{}' override has frame_subtile {} but primary animation only has {} tiles.",
1963 FormatParam{prim_anim_name, Style::bold},
1964 FormatParam{entry.frame_subtile},
1965 FormatParam{prim_tile_count}));
1966 diag_.error("primary-references-frame-subtile-oob", err_lines);
1967 continue;
1968 }
1969
1970 const std::size_t bin_index =
1971 entry.metatile_id * metatile::entries_per_metatile_triple +
1972 static_cast<std::size_t>(entry.layer) * metatile::tiles_per_metatile_layer +
1973 static_cast<std::size_t>(entry.subtile);
1974
1975 if (bin_index >= metatiles_bin.size()) {
1976 std::vector<std::string> err_lines;
1977 err_lines.emplace_back(format_.format(
1978 "Primary reference '{}' override references metatile_id {} which is out of range.",
1979 FormatParam{prim_anim_name, Style::bold},
1980 FormatParam{entry.metatile_id}));
1981 diag_.error("primary-references-metatile-oob", err_lines);
1982 continue;
1983 }
1984
1985 const std::size_t absolute_tile = prim_tile_offset + entry.frame_subtile;
1986 metatiles_bin.at(bin_index) = TilemapEntry{absolute_tile, entry.pal_index, entry.h_flip, entry.v_flip};
1987 }
1988 }
1989 }
1990}
1991
1992void CompilerTask::pipeline_helper_apply_true_color_to_tiles_png()
1993{
1994 // Phase 1: Build tile_index -> first_pal_index map from tilemap entries
1995 std::unordered_map<std::size_t, std::size_t> tile_to_first_pal;
1996 std::unordered_map<std::size_t, std::set<std::size_t>> tile_to_all_pals;
1997
1998 // Secondary tiles.png is densely packed from tile 0, but metatile entries reference absolute
1999 // indices (e.g., 512+ for secondary). This offset converts absolute to relative for image access.
2000 const std::size_t tile_index_offset = is_secondary() ? num_tiles_in_primary_.value() : 0;
2001
2002 // Secondary palettes are stored at absolute indices (e.g., 6-11), but the PNG palette only
2003 // covers this tileset's palettes (indices 0-5). This offset converts absolute to relative.
2004 // When a secondary tileset has a paired primary, the packer can assign tiles to primary palettes.
2005 // Use offset 0 so the encoding preserves absolute palette indices in the PNG pixel values.
2006 const std::size_t pal_index_offset = (is_secondary() && !has_paired_primary()) ? num_pals_in_primary_.value() : 0;
2007
2008 // For diagnostic display of unreferenced tiles, always use the first palette belonging to this tileset.
2009 const std::size_t default_display_pal = is_secondary() ? num_pals_in_primary_.value() : 0;
2010
2011 for (const auto &entry : new_porymap_component_->metatiles_bin()) {
2012 const auto tile_idx = entry.tile_index();
2013 const auto pal_idx = entry.pal_index();
2014
2015 if (tile_idx == 0) {
2016 continue; // Skip transparent tile
2017 }
2018
2019 tile_to_all_pals[tile_idx].insert(pal_idx);
2020
2021 if (!tile_to_first_pal.contains(tile_idx)) {
2022 tile_to_first_pal[tile_idx] = pal_idx;
2023 }
2024 }
2025
2026 // Phase 2: Handle animation-only tiles (not in metatiles_bin)
2027 for (const auto &[anim_name, source_anim] : tileset_.porytiles_component().anims()) {
2028 auto maybe_tile_offset = anim_tile_matcher_.tile_offset_for(anim_name);
2029 if (!maybe_tile_offset.has_value()) {
2030 continue;
2031 }
2032
2033 const std::size_t tile_offset = maybe_tile_offset.value();
2034 const AnimFrame<Rgba32> composite = source_anim.composite_frame(extrinsic_transparency_.value());
2035 const std::size_t tile_count = composite.tile_count();
2036
2037 for (std::size_t subtile_idx = 0; subtile_idx < tile_count; ++subtile_idx) {
2038 const std::size_t absolute_tile_idx = tile_offset + subtile_idx;
2039
2040 if (tile_to_first_pal.contains(absolute_tile_idx)) {
2041 continue; // Already mapped from metatiles_bin
2042 }
2043
2044 const PixelTile<Rgba32> &composite_tile = composite.tile_at(subtile_idx);
2045 std::vector<PaletteMatchResult<Rgba32>> matches =
2046 match_or_best(composite_tile, new_porymap_pals_, extrinsic_transparency_.value(), 1);
2047
2048 if (matches.at(0).is_covered) {
2049 const std::size_t matched_pal_idx = matches.at(0).pal_index;
2050 tile_to_first_pal[absolute_tile_idx] = matched_pal_idx;
2051
2052 // Extract the tile to check for transparency and for visualization
2053 const auto &tiles_img = new_porymap_component_->tiles_png();
2054 const PixelTile<IndexPixel> index_tile =
2055 extract_single_tile(tiles_img, absolute_tile_idx - tile_index_offset);
2056
2057 // Skip remark for transparent tiles (unused slots)
2058 if (index_tile.is_transparent()) {
2059 continue;
2060 }
2061
2062 // Emit remark for animation-only tiles not referenced in metatiles
2063 constexpr auto tag = "true-color-anim-only-tile";
2064 std::vector<std::string> remark_lines;
2065 remark_lines.emplace_back(format_.format(
2066 "Tile index '{}' (animation '{}', subtile '{}') is not referenced in metatiles.",
2067 FormatParam{absolute_tile_idx, Style::bold},
2068 FormatParam{anim_name, Style::bold},
2069 FormatParam{subtile_idx, Style::bold}));
2070 remark_lines.emplace_back(format_.format(
2071 "Using '{}' for true-color encoding (determined via palette matching).",
2072 FormatParam{pal_filename(matched_pal_idx), Style::bold}));
2073
2074 // Visualize the tile using the matched palette
2076 index_tile, new_porymap_pals_.at(matched_pal_idx), extrinsic_transparency_.value());
2077 remark_lines.emplace_back();
2078 remark_lines.append_range(tile_printer_.print_tile(rgba_tile, extrinsic_transparency_.value()));
2079
2080 diag_.remark(tag, remark_lines);
2081 }
2082 }
2083 }
2084
2085 // Phase 3: Emit diagnostic remark for tiles used with multiple palettes
2086 for (const auto &[absolute_tile_idx, pals] : tile_to_all_pals) {
2087 if (pals.size() > 1) {
2088 // Primary tiles are not in this tileset's tiles.png, skip
2089 if (absolute_tile_idx < tile_index_offset) {
2090 continue;
2091 }
2092
2093 // Extract the tile to check for transparency and for visualization
2094 const auto &tiles_img = new_porymap_component_->tiles_png();
2095 const PixelTile<IndexPixel> index_tile =
2096 extract_single_tile(tiles_img, absolute_tile_idx - tile_index_offset);
2097
2098 // Skip remark for transparent tiles (unused slots)
2099 if (index_tile.is_transparent()) {
2100 continue;
2101 }
2102
2103 constexpr auto tag = "true-color-multi-palette-tile";
2104 std::vector<std::string> remark_lines;
2105 remark_lines.emplace_back(format_.format(
2106 "Tile index '{}' is used with multiple palettes.", FormatParam{absolute_tile_idx, Style::bold}));
2107
2108 std::string pal_list;
2109 for (const auto pal : pals) {
2110 if (!pal_list.empty()) {
2111 pal_list += ", ";
2112 }
2113 pal_list += pal_filename(pal);
2114 }
2115
2116 const std::size_t selected_pal_idx = tile_to_first_pal.at(absolute_tile_idx);
2117 remark_lines.emplace_back(format_.format(
2118 "Palettes used: {}; tiles.png will display using '{}'.",
2119 FormatParam{pal_list},
2120 FormatParam{pal_filename(selected_pal_idx), Style::bold}));
2121
2122 // Visualize the tile under each palette resolution
2123 for (const auto pal_idx : pals) {
2124 remark_lines.emplace_back();
2125 remark_lines.emplace_back(
2126 format_.format("{} resolution:", FormatParam{pal_filename(pal_idx), Style::bold}));
2128 index_tile, new_porymap_pals_.at(pal_idx), extrinsic_transparency_.value());
2129 remark_lines.append_range(tile_printer_.print_tile(rgba_tile, extrinsic_transparency_.value()));
2130 }
2131
2132 diag_.remark(tag, remark_lines);
2133 }
2134 }
2135
2136 // Phase 4: Transform tiles_png pixels
2137 Image<IndexPixel> tiles_img = new_porymap_component_->tiles_png();
2138 constexpr std::size_t tiles_per_row = metatile::metatiles_per_row * metatile::tiles_per_side;
2139
2140 const std::size_t total_tiles = tiles_img.size_in_tiles();
2141
2142 for (std::size_t tile_idx = 1; tile_idx < total_tiles; ++tile_idx) {
2143 const std::size_t absolute_tile_idx = tile_idx + tile_index_offset;
2144 if (!tile_to_first_pal.contains(absolute_tile_idx)) {
2145 // Extract the tile to check for transparency
2146 const PixelTile<IndexPixel> index_tile = extract_single_tile(tiles_img, tile_idx, tiles_per_row);
2147
2148 // Skip remark for transparent tiles (unused slots) - user already knows they're unused
2149 if (index_tile.is_transparent()) {
2150 continue;
2151 }
2152
2153 // Emit remark for unreferenced non-transparent tiles
2154 constexpr auto tag = "true-color-unreferenced-tile";
2155 std::vector<std::string> remark_lines;
2156 remark_lines.emplace_back(format_.format(
2157 "Tile index '{}' is not referenced in metatiles or animations.",
2158 FormatParam{absolute_tile_idx, Style::bold}));
2159
2160 remark_lines.emplace_back("This tile may be used by a secondary tileset, or it may be completely unused.");
2161 remark_lines.emplace_back(format_.format(
2162 "Displaying using '{}' for color resolution.",
2163 FormatParam{pal_filename(default_display_pal), Style::bold}));
2164
2165 // Visualize the tile using the first palette for this tileset
2167 index_tile, new_porymap_pals_.at(default_display_pal), extrinsic_transparency_.value());
2168 remark_lines.emplace_back();
2169 remark_lines.append_range(tile_printer_.print_tile(rgba_tile, extrinsic_transparency_.value()));
2170
2171 diag_.remark(tag, remark_lines);
2172 continue; // Skip unreferenced tiles (no palette encoding needed)
2173 }
2174
2175 const std::size_t pal_idx = tile_to_first_pal.at(absolute_tile_idx);
2176 const std::size_t tile_row = tile_idx / tiles_per_row;
2177 const std::size_t tile_col = tile_idx % tiles_per_row;
2178 const std::size_t pixel_row_start = tile_row * tile::side_length_pix;
2179 const std::size_t pixel_col_start = tile_col * tile::side_length_pix;
2180
2181 for (std::size_t py = 0; py < tile::side_length_pix; ++py) {
2182 for (std::size_t px = 0; px < tile::side_length_pix; ++px) {
2183 const std::size_t row = pixel_row_start + py;
2184 const std::size_t col = pixel_col_start + px;
2185 const IndexPixel old_pixel = tiles_img.at(row, col);
2186 const std::size_t color_idx = old_pixel.color_index();
2187 const std::size_t new_index = ((pal_idx - pal_index_offset) << 4) | color_idx;
2188 tiles_img.set(row, col, IndexPixel{new_index});
2189 }
2190 }
2191 }
2192
2193 // Phase 5: Build the 8-bit palette for the PNG (this tileset's palettes * 16 colors)
2194 std::size_t num_pals;
2195 if (!is_secondary()) {
2196 num_pals = num_pals_in_primary_.value();
2197 }
2198 else if (has_paired_primary()) {
2199 num_pals = num_pals_total_.value();
2200 }
2201 else {
2202 num_pals = num_pals_total_.value() - num_pals_in_primary_.value();
2203 }
2204 std::vector<Rgba32> true_color_palette;
2205 true_color_palette.reserve(num_pals * pal::max_size);
2206
2207 for (std::size_t i = 0; i < num_pals; ++i) {
2208 const auto &pal = new_porymap_pals_.at(i + pal_index_offset);
2209 for (std::size_t color_idx = 0; color_idx < pal::max_size; ++color_idx) {
2210 true_color_palette.push_back(pal.at(color_idx));
2211 }
2212 }
2213
2214 tiles_img.palette(std::move(true_color_palette));
2215 new_porymap_component_->tiles_png(tiles_img);
2216}
2217
2218void CompilerTask::pipeline_helper_emit_no_matching_tile_error(
2219 std::size_t tile_index,
2220 const PixelTile<IndexPixel> &index_tile,
2221 std::size_t pal_index,
2222 const Palette<Rgba32, pal::max_size> &matched_pal)
2223{
2224 constexpr auto tag = "no-matching-tile";
2225 auto [metatile_index, layer, subtile] = metatile::from_tile_index(tile_index);
2226
2227 // Emit error
2228 std::vector<std::string> no_match_err{};
2229 no_match_err.emplace_back(format_.format(
2230 "{}: no matching tile found",
2231 FormatParam{metatile::message_header(format_, metatile_index, layer, subtile), Style::bold}));
2232 no_match_err.append_range(tile_printer_.print_metatile_tile_highlight(
2233 porytiles_metatiles_.at(metatile_index), layer, subtile, extrinsic_transparency_));
2234 diag_.error(tag, no_match_err);
2235
2236 // Print note showing the palette that matched
2237 std::vector<std::string> pal_note{};
2238 pal_note.emplace_back(format_.format("matched palette '{}':", FormatParam{pal_filename(pal_index), Style::bold}));
2239 pal_note.append_range(pal_printer_.print_rgba_pal(matched_pal));
2240 diag_.error_note(tag, pal_note);
2241
2242 // Print note showing the generated IndexPixel tile
2243 std::vector<std::string> tile_note{};
2244 tile_note.emplace_back("generated index tile:");
2245 tile_note.append_range(tile_printer_.print_tile(index_tile, extrinsic_transparency_.value()));
2246 diag_.error_note(tag, tile_note);
2247}
2248
2249void CompilerTask::pipeline_helper_emit_no_matching_pal_error(
2250 std::size_t tile_index, const std::vector<PaletteMatchResult<Rgba32>> &matches)
2251{
2252 constexpr auto tag = "no-matching-palette";
2253 auto [metatile_index, layer, subtile] = metatile::from_tile_index(tile_index);
2254
2255 // Emit error
2256 std::vector<std::string> no_match_err{};
2257 no_match_err.emplace_back(format_.format(
2258 "{}: no matching palette found",
2259 FormatParam{metatile::message_header(format_, metatile_index, layer, subtile), Style::bold}));
2260 no_match_err.append_range(tile_printer_.print_metatile_tile_highlight(
2261 porytiles_metatiles_.at(metatile_index), layer, subtile, extrinsic_transparency_));
2262 diag_.error(tag, no_match_err);
2263
2264 // Emit a long note showing the top N closest matches
2265 std::vector<std::string> closest_n_note{};
2266 closest_n_note.emplace_back("closest N match(es) with covered colors highlighted:");
2267 int match_index = 0;
2268 for (const auto &match : matches) {
2269 if (match_index != 0) {
2270 // Add a blank line between subsequent matches
2271 closest_n_note.emplace_back();
2272 }
2273 closest_n_note.push_back(
2274 format_.format("Palette match candidate: {}", FormatParam{pal_filename(match.pal_index), Style::bold}));
2275 closest_n_note.append_range(pal_printer_.print_rgba_palette_covered_missing(
2276 new_porymap_pals_.at(match.pal_index), match.covered_colors, match.missing_colors));
2277 closest_n_note.emplace_back();
2278 closest_n_note.push_back(
2279 format_.format("Uncovered pixels with {}:", FormatParam{pal_filename(match.pal_index), Style::bold}));
2280 closest_n_note.append_range(tile_printer_.print_metatile_pixel_highlights(
2281 porytiles_metatiles_.at(metatile_index),
2282 layer,
2283 subtile,
2284 match.uncovered_pixel_indices,
2285 extrinsic_transparency_));
2286 match_index++;
2287 }
2288 diag_.error_note(tag, closest_n_note);
2289}
2290
2291void CompilerTask::pipeline_helper_emit_tile_limit_error(std::size_t tile_index, std::size_t tile_limit)
2292{
2293 constexpr auto tag = "tile-limit";
2294 auto [metatile_index, layer, subtile] = metatile::from_tile_index(tile_index);
2295
2296 // Emit error
2297 std::vector<std::string> tile_limit_error{};
2298 tile_limit_error.emplace_back(format_.format(
2299 "{}: hit limit of '{}' unique tiles",
2300 FormatParam{metatile::message_header(format_, metatile_index, layer, subtile), Style::bold},
2301 FormatParam{tile_limit, Style::bold}));
2302 tile_limit_error.append_range(tile_printer_.print_metatile_tile_highlight(
2303 porytiles_metatiles_.at(metatile_index), layer, subtile, extrinsic_transparency_));
2304 diag_.error(tag, tile_limit_error);
2305
2306 // Construct note text
2307 std::vector<std::string> note_text;
2308 if (is_secondary()) {
2309 note_text.append_range(
2310 build_subtraction_limit_lines(format_, "Tile limit", tile_limit, num_tiles_total_, num_tiles_in_primary_));
2311 }
2312 else {
2313 note_text.push_back(
2314 format_.format("Tile limit is '{}' due to configuration.", FormatParam{tile_limit, Style::bold}));
2315 note_text.emplace_back();
2316 note_text.append_range(format_config_note(format_, num_tiles_in_primary_));
2317 }
2318 diag_.error_note(tag, note_text);
2319}
2320
2321} // namespace
2322
2323namespace porytiles {
2324
2326TilesetCompiler::compile(const Tileset &tileset, bool is_secondary, const Tileset *paired_primary) const
2327{
2328 CompilerTask task{tileset, is_secondary, paired_primary, *format_, *diag_, *tile_printer_, *pal_printer_, *config_};
2329 return task.run();
2330}
2331
2332} // namespace porytiles
#define PT_TRY_ASSIGN_CHAIN_ERR(var, expr, return_type,...)
Unwraps a ChainableResult, chaining a new error message on failure.
#define PT_TRY_CALL_PASS_ERR(expr, return_type)
Unwraps a void ChainableResult, passing through the error chain with an empty FormattableError when t...
#define PT_TRY_ASSIGN_PASS_ERR(var, expr, return_type)
Unwraps a ChainableResult, passing through the error chain with an empty FormattableError when types ...
#define PT_TRY_CALL_PASS_SAME_ERR(expr)
Unwraps a void ChainableResult, passing through the error unchanged when types match.
#define PT_TRY_CALL_CHAIN_ERR(expr, return_type,...)
Unwraps a void ChainableResult, chaining a new error message on failure.
Represents a single frame of an animation, containing tiles and a frame name.
const Palette< Rgba32 > & palette() const
const PixelTile< PixelType > & tile_at(std::size_t index) const
std::size_t tile_count() const
const std::vector< PixelTile< PixelType > > & tiles() const
Configuration parameters for a single tileset animation.
std::size_t tile_offset() const
Returns the VRAM tile offset for this animation.
std::size_t tile_count() const
Returns the number of tiles per animation frame.
Matches tiles against animation keyframe tiles for compilation.
A complete tileset animation with name, configuration, and frame data.
Definition animation.hpp:78
const AnimParams & params() const
Definition animation.hpp:91
AnimFrame< PixelType > composite_frame(const PixelType &extrinsic_transparency) const
Returns the "composite" frame for this animation.
bool has_frames() const
Checks if this animation has any frames.
bool has_key_frame() const
Checks if this animation has a key frame set.
const AnimFrame< PixelType > & key_frame() const
Returns the key frame of this animation.
const std::map< std::string, AnimFrame< PixelType > > & frames() const
A PixelTile representation that stores the canonical (lexicographically minimal) orientation among al...
A result type that maintains a chainable sequence of errors for debugging and error reporting.
T & value() &
Returns a reference to the contained success value.
A bidirectional mapping between pixel color values and sequential integer indices.
void add_tile(const PixelTile< PixelType > &tile)
Adds colors from a single tile to the mapping using intrinsic transparency.
A container that wraps a configuration value with its name and source information.
ConfigValue< U > derive(const ConfigPODField< U > &override) const
Creates a child ConfigValue from a ConfigPODField, inheriting this value's source provenance.
Interface that defines a complete domain layer configuration.
A text parameter with associated styling for formatted output.
General-purpose error implementation with formatted message support.
Definition error.hpp:63
Service for converting images into collections of 8x8 tiles.
A template for two-dimensional images with arbitrarily typed pixel values.
Definition image.hpp:23
void set(std::size_t i, PixelType pixel)
Sets the pixel value at a given one-dimensional pixel index.
Definition image.hpp:88
std::size_t size_in_tiles() const
Gets the number of 8x8 tile regions in this image.
Definition image.hpp:139
const std::optional< std::vector< Rgba32 > > & palette() const
Definition image.hpp:150
PixelType at(std::size_t i) const
Fetches the pixel value at a given one-dimensional pixel index.
Definition image.hpp:56
Represents an indexed color pixel.
std::size_t color_index() const
Returns the color index within a palette (lower 4 bits).
Service for converting layer images into collections of metatiles.
Represents the attributes of a single metatile.
Domain service that packs ColorSets into hardware palettes.
A collection of printer functions for the Palette and related types.
A generic palette container for colors that support transparency checking.
Definition palette.hpp:47
An 8x8 tile backed by literal-array-based per-pixel storage of an arbitrary pixel type.
bool is_transparent() const
Checks if this entire PixelTile is transparent (intrinsic transparency only).
PixelType at(std::size_t i) const
Represents a 32-bit RGBA color.
Definition rgba32.hpp:23
static constexpr std::uint8_t alpha_opaque
Definition rgba32.hpp:26
static const Style bold
Bold text formatting.
Abstract base class for applying text styling with context-aware formatting.
A collection of printer functions for various tile types.
Represents a tilemap entry referencing a tile with palette and flip attributes.
std::size_t tile_index() const
std::size_t pal_index() const
static TilesPngWorkspace for_secondary(const Image< IndexPixel > &primary_tiles_png, std::size_t primary_tile_count, std::size_t total_capacity)
Creates a workspace pre-loaded with primary tiles for secondary tileset compilation.
static TilesPngWorkspace for_standalone_secondary(std::size_t primary_tile_count, std::size_t total_capacity)
Creates a workspace for standalone secondary compilation with no paired primary.
ChainableResult< std::unique_ptr< Tileset > > compile(const Tileset &tileset, bool is_secondary=false, const Tileset *paired_primary=nullptr) const
Compiles the given Tileset, producing a new Tileset with compiled Porymap assets.
A complete tileset containing both Porytiles and Porymap components.
Definition tileset.hpp:14
Abstract class for structured error reporting and diagnostic output.
constexpr std::size_t entries_per_metatile_triple
Definition metatile.hpp:21
constexpr std::size_t tiles_per_side
Definition metatile.hpp:16
std::tuple< std::size_t, Layer, Subtile > from_tile_index(std::size_t tile_index)
Decomposes a global tile index into its metatile index, layer, and subtile position.
Definition metatile.hpp:90
constexpr std::size_t metatiles_per_row
Definition metatile.hpp:22
constexpr std::size_t tiles_per_metatile_layer
Definition metatile.hpp:17
constexpr std::size_t num_pals
Definition palette.hpp:21
constexpr std::size_t max_size
Definition palette.hpp:19
constexpr std::size_t side_length_pix
PixelTile< PixelType > extract_single_tile(const Image< PixelType > &img, std::size_t tile_idx, std::size_t tiles_per_row=metatile::metatiles_per_row *metatile::tiles_per_side)
Extracts a single tile from an image at a given tile index.
ChainableResult< void > validate_porymap_pal(const TilesetCompileValidatorServices &services, const std::string &tileset_name, const Palette< Rgba32, pal::max_size > &pal, std::size_t pal_index)
Validates a Porymap palette for correctness according to GBA hardware constraints.
void panic(const StringViewSourceLoc &s)
Unconditionally terminates the program with a panic message.
Definition panic.cpp:43
void assert_or_panic(bool condition, const StringViewSourceLoc &s)
Conditionally panics if the given condition is false.
Definition panic.cpp:53
ChainableResult< void > validate_porytiles_pal(const TilesetCompileValidatorServices &services, const std::string &tileset_name, const Palette< Rgba32, pal::max_size > &pal, std::size_t pal_index)
Validates a user-specified Porytiles override palette for correctness.
ChainableResult< void > validate_anim_frames(const TilesetCompileValidatorServices &services, const std::string &tileset_name, const std::map< std::string, Animation< Rgba32 > > &anims)
Validates animation frames for correctness according to tileset compilation constraints.
ChainableResult< void > validate_alpha_channels(const TilesetCompileValidatorServices &services, const std::string &tileset_name, const std::vector< Metatile< Rgba32 > > &metatiles, const std::map< std::string, Animation< Rgba32 > > &anims)
Validates that all pixel alpha channels in provided input have valid values.
ChainableResult< void > validate_pal_hint(const TilesetCompileValidatorServices &services, const std::string &tileset_name, const PaletteHint &hint)
Validates a user-specified palette hint for correctness.
@ canonical
Export tiles in canonical form without applying flip transformations.
LayerMode layer_mode_from_val(std::size_t s)
Converts a numeric value to LayerMode.
Definition layer.hpp:32
PixelTile< IndexPixel > index_tile_from_color_tile(const PixelTile< ColorType > &tile, const Palette< ColorType, N > &palette)
Converts a PixelTile<ColorType> to indexed form using a palette (intrinsic transparency only).
std::vector< std::string > build_subtraction_limit_lines(const TextFormatter &format, std::string_view label, std::size_t computed_limit, const ConfigValue< std::size_t > &total_cfg, const ConfigValue< std::size_t > &primary_cfg)
Builds note lines explaining a derived limit computed as total minus primary.
std::string pal_filename(std::size_t pal_index)
Constructs a palette filename from a palette index.
std::vector< std::string > format_config_note(const TextFormatter &format, const ConfigValue< T > &config)
Format a ConfigValue into diagnostic note lines.
ChainableResult< void > validate_metatile_count(const TilesetCompileValidatorServices &services, const std::string &tileset_name, bool is_secondary, const std::vector< Metatile< Rgba32 > > &metatiles)
Validates that the metatile count does not exceed the configured limit.
ChainableResult< void > validate_layer_mode(const TilesetCompileValidatorServices &services, const std::string &tileset_name, const std::vector< Metatile< Rgba32 > > &metatiles)
Validates that metatiles conform to the configured layer mode.
LayerType
Specifies which layers of a metatile are used for rendering.
Definition layer.hpp:97
std::vector< std::string > format_config_note_with_separator(const TextFormatter &format, const ConfigValue< T > &config)
Format a ConfigValue into diagnostic note lines with a separator.
std::vector< PaletteMatchResult< ColorType > > match_or_best(const PixelTile< ColorType > &tile, const PaletteContainer &palettes, const ColorType &extrinsic, std::size_t top_n)
Finds the best palette match(es) for a tile (extrinsic transparency).
ChainableResult< void > validate_precision_loss(const TilesetCompileValidatorServices &services, const std::string &tileset_name, const std::vector< Metatile< Rgba32 > > &metatiles, const std::map< std::string, Animation< Rgba32 > > &anims, const std::array< std::optional< Palette< Rgba32, pal::max_size > >, pal::num_pals > &porytiles_pals, const std::vector< PaletteHint > &hints, const std::optional< std::array< Palette< Rgba32, pal::max_size >, pal::num_pals > > &porymap_pals)
Validates that no colors will suffer unacceptable precision loss during GBA color conversion.
PixelTile< ColorType > color_tile_from_index_tile(const PixelTile< IndexPixel > &index_tile, const Palette< ColorType, N > &palette)
Converts a PixelTile<IndexPixel> to a PixelTile<ColorType> using a palette (intrinsic transparency).
@ tileset
Configuration scoped to a specific tileset.
ChainableResult< void > validate_tile_color_count(const TilesetCompileValidatorServices &services, const std::string &tileset_name, const std::vector< Metatile< Rgba32 > > &metatiles, const std::map< std::string, Animation< Rgba32 > > &anims)
Validates that each individual tile does not exceed the per-tile color limit.
std::string to_string(const PrimaryPairingMode m)
Converts a PrimaryPairingMode to its canonical string representation.
ChainableResult< void > validate_global_color_count(const TilesetCompileValidatorServices &services, const std::string &tileset_name, bool is_secondary, const std::vector< Metatile< Rgba32 > > &metatiles, const std::map< std::string, Animation< Rgba32 > > &anims, const std::array< std::optional< Palette< Rgba32, pal::max_size > >, pal::num_pals > &porytiles_pals, const std::vector< PaletteHint > &hints)
Validates that the total unique color count across all input does not exceed the global limit.
PackingStrategyType
Selects the palette packing algorithm to use during tileset compilation.
Utility functions for string manipulation and formatting.
A reconstructed RGBA tile from a compiled primary tileset, tagged with its first metatile slot locati...
The input parameters for a packing operation.
std::vector< PixelTile< Rgba32 > > tiles_
Raw pixel tiles to pack into palettes.
Container for per-strategy packing parameters.
Result type for palette matching operations.
Parameter store for common services used by tileset compile validators.
Validation functions for tileset compile job input.
#define PT_UNWRAP_TILESET_CONFIG_REF(ref, config, tileset_name, return_type)
Unwraps a tileset-scoped config value via reference access, returning early if the value is not avail...