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