Porytiles
Loading...
Searching...
No Matches
project_tileset_artifact_writer.cpp
Go to the documentation of this file.
2
3#include <algorithm>
4#include <filesystem>
5#include <format>
6#include <fstream>
7#include <iostream>
8#include <map>
9#include <memory>
10#include <optional>
11#include <random>
12#include <ranges>
13#include <sstream>
14#include <string>
15
29
30namespace {
31
32using namespace porytiles;
33
34// Marker directories for artifact categorization
35const std::filesystem::path porytiles_src_marker{"porytiles_src"};
36const std::filesystem::path porytiles_bin_marker{"porytiles_bin"};
37const std::filesystem::path porytiles_generated_marker{"porytiles_generated"};
38
46enum class ArtifactCategory { porytiles_src, porytiles_bin, special };
47
54struct ArtifactPathInfo {
55 ArtifactCategory category;
56 std::filesystem::path directory;
57};
58
68[[nodiscard]] ArtifactPathInfo categorize_artifact_key(const std::filesystem::path &key_path)
69{
70 // Walk through path components to find marker directories
71 std::filesystem::path accumulated;
72 for (const auto &component : key_path) {
73 accumulated /= component;
74
75 if (component == porytiles_src_marker) {
76 return ArtifactPathInfo{ArtifactCategory::porytiles_src, accumulated};
77 }
78 if (component == porytiles_bin_marker) {
79 return ArtifactPathInfo{ArtifactCategory::porytiles_bin, accumulated};
80 }
81 if (component == porytiles_generated_marker) {
82 // For porytiles_generated, the whole path up to the file's parent is special
83 return ArtifactPathInfo{ArtifactCategory::special, key_path.parent_path()};
84 }
85 }
86
87 // Default to special category for unrecognized paths
88 return ArtifactPathInfo{ArtifactCategory::special, key_path.parent_path()};
89}
90
100[[nodiscard]] std::filesystem::path create_project_tmpdir(const std::filesystem::path &project_root)
101{
102 int max_tries = 1000;
103 std::random_device random_device;
104 std::mt19937 mersenne_prng(random_device());
105 std::uniform_int_distribution<uint64_t> uniform_int_distribution(0);
106 std::filesystem::path path;
107
108 for (int i = 0; i <= max_tries; ++i) {
109 std::stringstream string_stream;
110 string_stream << std::hex << uniform_int_distribution(mersenne_prng);
111 path = project_root / (".porytiles_tmp_" + string_stream.str());
112 if (std::filesystem::create_directory(path)) {
113 return path;
114 }
115 if (i == max_tries) {
116 panic("create_project_tmpdir: exceeded maximum retries");
117 }
118 }
119 panic("create_project_tmpdir: unreachable");
120 return {}; // unreachable
121}
122
124save_layer_png(const PngRgbaImageSaver &saver, const Image<Rgba32> &layer_png, const std::filesystem::path &path)
125{
126 auto result = saver.save_to_file(layer_png, path);
127 if (!result.has_value()) {
128 return result;
129 }
130 return {};
131}
132
133ChainableResult<void> save_tiles_png(
134 const PngIndexedImageSaver &saver,
135 const Image<IndexPixel> &tiles_png,
136 const std::filesystem::path &path,
137 TilesPaletteMode tiles_palette_mode)
138{
139 auto result = saver.save_to_file(tiles_png, path, tiles_palette_mode);
140 if (!result.has_value()) {
141 return result;
142 }
143 return {};
144}
145
146ChainableResult<void> save_metatiles_bin(const std::vector<TilemapEntry> &entries, const std::filesystem::path &path)
147{
148 std::ofstream out{path};
149 for (const auto &entry : entries) {
150 // TODO: metatiles are a fixed format, these magic numbers could probably live somewhere else
151 const auto tile_value = static_cast<uint16_t>(
152 (entry.tile_index() & 0x3ffu) | ((entry.h_flip() & 1u) << 10u) | ((entry.v_flip() & 1u) << 11u) |
153 ((entry.palette_index() & 0xfu) << 12u));
154 out << static_cast<std::uint8_t>(tile_value);
155 out << static_cast<std::uint8_t>(tile_value >> 8u);
156 }
157 out.flush();
158 return {};
159}
160
161ChainableResult<void> save_palette(
162 const Palette<Rgba32, palette::max_size> &palette, const std::filesystem::path &path, const FilePaletteSaver &saver)
163{
164 PT_TRY_CALL_CHAIN_ERR(saver.save(palette, path), void, "'{}': Failed to save.", FormatParam(path.c_str()));
165 return {};
166}
167
168ChainableResult<void> save_porymap_anim_frame(
169 const PngIndexedImageSaver &saver,
170 const Image<IndexPixel> &frame,
171 const std::filesystem::path &path,
172 TilesPaletteMode tiles_palette_mode)
173{
174 auto result = saver.save_to_file(frame, path, tiles_palette_mode);
175 if (!result.has_value()) {
176 return result;
177 }
178 return {};
179}
180
198template <typename StagedDirectory>
199ChainableResult<std::filesystem::path> compute_transaction_dest_path(
200 const std::filesystem::path &transaction_root,
201 const std::filesystem::path &project_root,
202 const ArtifactKey &dest_key,
203 std::map<std::filesystem::path, StagedDirectory> &staged_directories,
204 std::vector<std::pair<std::filesystem::path, std::filesystem::path>> &staged_special_files)
205{
206 if (transaction_root.empty()) {
207 return FormattableError{"No transaction in progress."};
208 }
209
210 const std::filesystem::path key_path{dest_key.key()};
211 const auto path_info = categorize_artifact_key(key_path);
212
213 if (path_info.category == ArtifactCategory::special) {
214 // Special files are handled individually - stage them directly
215 const auto staging_path = transaction_root / key_path;
216 std::filesystem::create_directories(staging_path.parent_path());
217
218 // Register for individual file commit
219 const auto dest_path = project_root / key_path;
220 staged_special_files.emplace_back(staging_path, dest_path);
221
222 return staging_path;
223 }
224
225 // For porytiles_src/porytiles_bin, stage under a directory that will be atomically moved
226 const auto &category_dir = path_info.directory;
227 const auto dest_dir = project_root / category_dir;
228
229 // Register this directory if not already registered
230 if (!staged_directories.contains(dest_dir)) {
231 // Create a unique staging directory for this category
232 const auto staging_dir = transaction_root / category_dir;
233 staged_directories[dest_dir] = StagedDirectory{staging_dir, dest_dir};
234 }
235
236 // Compute the path relative to the category directory
237 const auto relative_within_category = std::filesystem::relative(key_path, category_dir);
238 const auto staging_path = staged_directories[dest_dir].staging_path / relative_within_category;
239
240 // Create parent directories in staging area
241 std::filesystem::create_directories(staging_path.parent_path());
242
243 return staging_path;
244}
245
258template <typename PixelType>
259Image<PixelType> tiles_to_image(
260 const std::vector<PixelTile<PixelType>> &tiles, std::size_t width_tiles = 0, std::size_t height_tiles = 0)
261{
262 if (tiles.empty()) {
263 return Image<PixelType>{};
264 }
265
266 // Determine grid dimensions
267 std::size_t tiles_per_row;
268 std::size_t tiles_per_col;
269
270 if (width_tiles > 0 && height_tiles > 0) {
271 // Use specified dimensions
272 if (width_tiles * height_tiles != tiles.size()) {
273 panic(
274 std::format(
275 "tiles_to_image: width_tiles ({}) * height_tiles ({}) != tiles.size() ({})",
276 width_tiles,
277 height_tiles,
278 tiles.size()));
279 }
280 tiles_per_row = width_tiles;
281 tiles_per_col = height_tiles;
282 }
283 else {
284 // Fall back to single row
285 tiles_per_row = tiles.size();
286 tiles_per_col = 1;
287 }
288
289 const std::size_t image_width = tiles_per_row * tile::side_length_pix;
290 const std::size_t image_height = tiles_per_col * tile::side_length_pix;
291
292 Image<PixelType> img{image_width, image_height};
293
294 for (std::size_t tile_idx = 0; tile_idx < tiles.size(); ++tile_idx) {
295 const auto &tile = tiles[tile_idx];
296 const std::size_t tile_row = tile_idx / tiles_per_row;
297 const std::size_t tile_col = tile_idx % tiles_per_row;
298 const std::size_t pixel_row_offset = tile_row * tile::side_length_pix;
299 const std::size_t pixel_col_offset = tile_col * tile::side_length_pix;
300
301 for (std::size_t pixel_row = 0; pixel_row < tile::side_length_pix; ++pixel_row) {
302 for (std::size_t pixel_col = 0; pixel_col < tile::side_length_pix; ++pixel_col) {
303 const std::size_t dest_row = pixel_row_offset + pixel_row;
304 const std::size_t dest_col = pixel_col_offset + pixel_col;
305 img.set(dest_row, dest_col, tile.at(pixel_row, pixel_col));
306 }
307 }
308 }
309
310 return img;
311}
312
336template <SupportsTransparency PixelType, typename StagedDirectory, typename ComponentGetter, typename SaveFunc>
337ChainableResult<void> write_anim_frame_impl(
338 const ArtifactKey &dest_key,
339 const Tileset &src,
340 const std::string &anim_name,
341 const std::string &frame_name,
342 const std::filesystem::path &transaction_root,
343 const std::filesystem::path &project_root,
344 std::map<std::filesystem::path, StagedDirectory> &staged_directories,
345 std::vector<std::pair<std::filesystem::path, std::filesystem::path>> &staged_special_files,
346 ComponentGetter component_getter,
347 SaveFunc save_func,
348 std::string_view component_name)
349{
350 const auto &component = component_getter(src);
351
352 if (!component.has_anim(anim_name)) {
353 return FormattableError{
354 "animation '{}' not found in {} component",
355 FormatParam{anim_name, Style::bold},
356 FormatParam{std::string{component_name}}};
357 }
358
359 const auto &anim = component.anim_for_name(anim_name);
360
361 // Get the appropriate frame
362 const AnimFrame<PixelType> *frame_ptr = nullptr;
363 if (frame_name != "key") {
364 frame_ptr = &anim.frame_for_name(frame_name);
365 }
366 else {
367 frame_ptr = &anim.key_frame();
368 }
369
370 // Convert tiles to image
371 const auto &params = anim.params();
372 auto img = tiles_to_image(frame_ptr->tiles(), params.width_tiles(), params.height_tiles());
373
374 // Transfer palette from frame to image if present
375 if (frame_ptr->has_palette()) {
376 const auto &palette = frame_ptr->palette();
377 std::vector<Rgba32> palette_vec;
378 palette_vec.reserve(palette.size());
379 for (std::size_t i = 0; i < palette.size(); ++i) {
380 palette_vec.push_back(palette.at(i));
381 }
382 img.palette(std::move(palette_vec));
383 }
384
385 // Compute transaction path (keys are now relative to project_root)
387 transaction_dest_path,
388 compute_transaction_dest_path(
389 transaction_root, project_root, dest_key, staged_directories, staged_special_files),
390 void,
391 "Failed to compute transaction dest path.");
392
393 // Save using provided save function
394 return save_func(img, transaction_dest_path);
395}
396
397} // namespace
398
399namespace porytiles {
400
402{
403 if (!transaction_root_.empty()) {
404 return FormattableError{"Transaction already in progress."};
405 }
406
407 // Create tmpdir inside project root to ensure same-filesystem for atomic moves
408 transaction_root_ = create_project_tmpdir(project_root_);
409
410 // Clear any stale tracking data
411 staged_directories_.clear();
412 staged_special_files_.clear();
413
414 return {};
415}
416
418{
419 if (transaction_root_.empty()) {
420 return FormattableError{"No transaction in progress."};
421 }
422
423 // If nothing was staged, just clean up
424 if (staged_directories_.empty() && staged_special_files_.empty()) {
425 std::filesystem::remove_all(transaction_root_);
426 transaction_root_.clear();
427 return {};
428 }
429
430 // Create backup root inside project for same-filesystem operations
431 const auto backup_root = create_project_tmpdir(project_root_);
432
433 // Track what we've moved for rollback
434 std::vector<std::pair<std::filesystem::path, std::filesystem::path>> moved_directories; // (dest, backup)
435 std::vector<std::pair<std::filesystem::path, std::filesystem::path>> backed_up_special_files;
436 std::vector<std::filesystem::path> new_special_files;
437
438 try {
439 // Phase 1: Backup existing destination directories by moving them to backup
440 for (const auto &[dest_dir, staged_info] : staged_directories_) {
441 if (std::filesystem::exists(dest_dir)) {
442 // Create backup path preserving structure
443 const auto relative = std::filesystem::relative(dest_dir, project_root_);
444 const auto backup_path = backup_root / relative;
445 std::filesystem::create_directories(backup_path.parent_path());
446
447 // Move existing directory to backup (atomic on same filesystem)
448 std::filesystem::rename(dest_dir, backup_path);
449 moved_directories.emplace_back(dest_dir, backup_path);
450 }
451 }
452
453 // Phase 2: Atomic directory moves from staging to destination
454 for (const auto &[dest_dir, staged_info] : staged_directories_) {
455 // Create parent directories if needed
456 std::filesystem::create_directories(dest_dir.parent_path());
457
458 // Atomic move: rename staging directory to final destination
459 std::filesystem::rename(staged_info.staging_path, dest_dir);
460 }
461
462 // Phase 3: Handle special files (like generated_anim_code.h)
463 for (const auto &[staging_path, dest_path] : staged_special_files_) {
464 // Backup existing special file if it exists
465 if (std::filesystem::exists(dest_path)) {
466 const auto relative = std::filesystem::relative(dest_path, project_root_);
467 const auto backup_path = backup_root / relative;
468 std::filesystem::create_directories(backup_path.parent_path());
469 std::filesystem::copy_file(dest_path, backup_path);
470 backed_up_special_files.emplace_back(dest_path, backup_path);
471 }
472 else {
473 new_special_files.push_back(dest_path);
474 }
475
476 // Copy special file to destination (create dirs if needed)
477 std::filesystem::create_directories(dest_path.parent_path());
478 std::filesystem::copy_file(staging_path, dest_path, std::filesystem::copy_options::overwrite_existing);
479 }
480
481 // Phase 4: Success - clean up transaction and backup directories
482 std::filesystem::remove_all(transaction_root_);
483 std::filesystem::remove_all(backup_root);
484 transaction_root_.clear();
485 staged_directories_.clear();
486 staged_special_files_.clear();
487
488 return {};
489 }
490 catch (const std::filesystem::filesystem_error &e) {
491 // Phase 5: Error occurred - rollback
492 try {
493 // Rollback moved directories: move backups back to their original locations
494 for (const auto &[original_path, backup_path] : moved_directories) {
495 if (std::filesystem::exists(backup_path)) {
496 // Remove any partially moved directory at destination
497 if (std::filesystem::exists(original_path)) {
498 std::filesystem::remove_all(original_path);
499 }
500 std::filesystem::rename(backup_path, original_path);
501 }
502 }
503
504 // Rollback special files
505 for (const auto &[original_path, backup_path] : backed_up_special_files) {
506 if (std::filesystem::exists(backup_path)) {
507 std::filesystem::copy_file(
508 backup_path, original_path, std::filesystem::copy_options::overwrite_existing);
509 }
510 }
511
512 // Remove new special files that were created
513 for (const auto &new_file : new_special_files) {
514 if (std::filesystem::exists(new_file)) {
515 std::filesystem::remove(new_file);
516 }
517 }
518 }
519 catch (const std::filesystem::filesystem_error &) {
520 // Critical error during restore - best effort cleanup
521 }
522
523 // Clean up temporary directories
524 if (std::filesystem::exists(backup_root)) {
525 std::filesystem::remove_all(backup_root);
526 }
527 if (std::filesystem::exists(transaction_root_)) {
528 std::filesystem::remove_all(transaction_root_);
529 }
530 transaction_root_.clear();
531 staged_directories_.clear();
532 staged_special_files_.clear();
533
534 return FormattableError{"Failed to commit transaction: {}.", FormatParam{e.what()}};
535 }
536}
537
539{
540 if (transaction_root_.empty()) {
541 return FormattableError{"No transaction in progress."};
542 }
543
544 try {
545 if (std::filesystem::exists(transaction_root_)) {
546 std::filesystem::remove_all(transaction_root_);
547 }
548 transaction_root_.clear();
549 staged_directories_.clear();
550 staged_special_files_.clear();
551 return {};
552 }
553 catch (const std::filesystem::filesystem_error &e) {
554 transaction_root_.clear();
555 staged_directories_.clear();
556 staged_special_files_.clear();
557 return FormattableError{"Failed to rollback transaction: {}.", FormatParam{e.what()}};
558 }
559}
560
561// Porymap artifacts
563{
565 transaction_dest_path,
566 compute_transaction_dest_path(
567 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
568 void,
569 "Failed to compute transaction dest path.");
570 return save_metatiles_bin(src.porymap_component().metatiles_bin(), transaction_dest_path);
571}
572
575{
577 transaction_dest_path,
578 compute_transaction_dest_path(
579 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
580 void,
581 "Failed to compute transaction dest path.");
583 src.porymap_component().metatile_attributes_bin(), transaction_dest_path, *schema_);
584}
585
587{
589 transaction_dest_path,
590 compute_transaction_dest_path(
591 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
592 void,
593 "Failed to compute transaction dest path.");
595 tiles_palette_mode_config,
596 domain_config_->tiles_palette_mode(ConfigScopeType::tileset, src.name()),
597 void,
598 "Failed to get tiles_palette_mode config.");
599 return save_tiles_png(
600 *png_indexed_saver_,
602 transaction_dest_path,
603 tiles_palette_mode_config.value());
604}
605
607 const ArtifactKey &dest_key, const Tileset &src, std::size_t index)
608{
610 transaction_dest_path,
611 compute_transaction_dest_path(
612 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
613 void,
614 "Failed to compute transaction dest path.");
615 const auto &palette = src.porymap_component().palette_at(index);
616 if (palette.has_any_wildcards()) {
617 panic("attempted to save a Porymap palette containing wildcards");
618 }
619 return save_palette(palette, transaction_dest_path, *palette_saver_);
620}
621
623 const ArtifactKey &dest_key, const Tileset &src, const std::string &anim_name, const std::string &frame_name)
624{
626 tiles_palette_mode_config,
627 domain_config_->tiles_palette_mode(ConfigScopeType::tileset, src.name()),
628 void,
629 "Failed to get tiles_palette_mode config.");
630 return write_anim_frame_impl<IndexPixel>(
631 dest_key,
632 src,
633 anim_name,
634 frame_name,
635 transaction_root_,
636 project_root_,
637 staged_directories_,
638 staged_special_files_,
639 [](const Tileset &t) -> const auto & { return t.porymap_component(); },
640 [this, &tiles_palette_mode_config](const Image<IndexPixel> &img, const std::filesystem::path &path) {
641 return save_porymap_anim_frame(*png_indexed_saver_, img, path, tiles_palette_mode_config.value());
642 },
643 "Porymap");
644}
645
646[[nodiscard]] ChainableResult<void>
648{
649 const auto &porymap_anims = src.porymap_component().anims();
650 if (porymap_anims.empty()) {
651 // If there are no anims, but the params file exists, remove it
652 if (std::filesystem::exists(project_root_ / dest_key.key())) {
653 std::filesystem::remove(project_root_ / dest_key.key());
654 }
655 return {};
656 }
657
658 std::map<DynamicCasedName, AnimParams> anim_params;
659 for (const auto &[anim_name, anim] : porymap_anims) {
660 anim_params[DynamicCasedName{anim_name}] = anim.params();
661 }
662
663 // Determine primary/secondary from metadata
665 is_secondary,
666 metadata_provider_.is_secondary(src.name()),
667 void,
668 "Failed to determine primary/secondary status for '{}'.",
670 const bool is_primary = !is_secondary;
671
672 // Read tileset bin path from config based on primary/secondary status
674 bin_path_base,
675 is_primary ? infra_config_->tileset_paths_primary_bin(ConfigScopeType::tileset, src.name())
677 void,
678 "Failed to get tileset bin path config for '{}'.",
680 const std::filesystem::path tileset_path =
681 std::filesystem::path{bin_path_base.value()} / extract_tileset_cased_name(src.name()).to_snake_case();
682
684 generated_code,
685 anim_code_generator_->generate(src.name(), tileset_path, anim_params, is_primary),
686 void,
687 "Failed to generate animation code for '{}'.",
689
691 transaction_dest_path,
692 compute_transaction_dest_path(
693 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
694 void,
695 "Failed to compute transaction dest path.");
696
697 std::ofstream out{transaction_dest_path};
698 if (!out.is_open()) {
699 return FormattableError{
700 "Failed to open file for writing: '{}'.", FormatParam{transaction_dest_path.string(), Style::bold}};
701 }
702 out << generated_code;
703 out.flush();
704
705 return {};
706}
707
708// Porytiles artifacts
710{
712 transaction_dest_path,
713 compute_transaction_dest_path(
714 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
715 void,
716 "Failed to compute transaction dest path.");
717 return save_layer_png(*png_rgba_saver_, src.porytiles_component().bottom(), transaction_dest_path);
718}
719
721{
723 transaction_dest_path,
724 compute_transaction_dest_path(
725 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
726 void,
727 "Failed to compute transaction dest path.");
728 return save_layer_png(*png_rgba_saver_, src.porytiles_component().middle(), transaction_dest_path);
729}
730
732{
734 transaction_dest_path,
735 compute_transaction_dest_path(
736 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
737 void,
738 "Failed to compute transaction dest path.");
739 return save_layer_png(*png_rgba_saver_, src.porytiles_component().top(), transaction_dest_path);
740}
741
744{
745 const auto &attributes = src.porytiles_component().metatile_attributes();
746
748 role_pins_cv,
749 infra_config_->role_pins(ConfigScopeType::tileset, src.name()),
750 void,
751 "Failed to resolve role_pins.");
752 const RolePinDefinitions &role_pins = role_pins_cv.value();
753
755 transaction_dest_path,
756 compute_transaction_dest_path(
757 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
758 void,
759 "Failed to compute transaction dest path.");
760
761 std::ofstream out{transaction_dest_path};
762 if (!out.is_open()) {
763 return FormattableError{
764 "Failed to open file for writing: '{}'.", FormatParam{transaction_dest_path.string(), Style::bold}};
765 }
766
767 // Write header: id plus every schema value field name in schema order, then one trailing pin column per role pin in
768 // config order. A pin column's name is fixed at "pin::<role>", so the header records which columns are pin columns
769 // and the loader never has to infer that from a column's position. A role-bearing field is not a true value column;
770 // the CSV addresses a role's per-metatile value only through its pin column.
771 std::string header = "id";
772 for (const Field &field : schema_->value_fields()) {
773 header += "," + field.name();
774 }
775 for (const RolePinDefinition &pin : role_pins) {
776 header += "," + pin_column_name(pin.role);
777 }
778 out << header << "\n";
779
780 // A field absent from an attribute takes its schema default. MetatileAttribute::field() reads an absent field as
781 // 0, but a schema field may declare a nonzero default, so the effective value must come through the schema. This
782 // one value drives both cell rendering and all-default row omission.
783 auto effective_value = [](const MetatileAttribute &attribute, const Field &field) -> std::uint32_t {
784 return attribute.fields().contains(field.name()) ? attribute.field(field.name()) : field.default_value();
785 };
786
787 // Renders a row's field cells in schema order, without the id or layer_type. A provider-backed field renders its
788 // value's constant name; a raw field renders the plain integer. has_provider() is the authority for that split,
789 // and build_provider_map upholds has_provider() <=> map membership, so a missing provider is an internal bug.
790 auto render_fields = [&](const MetatileAttribute &attribute,
791 std::size_t metatile_id) -> ChainableResult<std::string> {
792 std::string cells{};
793 for (const Field &field : schema_->value_fields()) {
794 if (!cells.empty()) {
795 cells += ",";
796 }
797 const std::uint32_t value = effective_value(attribute, field);
798 if (!field.has_provider()) {
799 cells += std::to_string(value);
800 continue;
801 }
802 const auto provider_it = providers_->find(field.name());
803 if (provider_it == providers_->end()) {
804 panic(
805 std::format(
806 "write_attributes_csv: field '{}' has a provider spec but no provider was built for it",
807 field.name()));
808 }
810 field_name,
811 provider_it->second->lookup(value),
812 std::string,
813 std::format("Failed to lookup {} name for metatile {}.", field.name(), metatile_id));
814 cells += field_name;
815 }
816 return cells;
817 };
818
819 // A row is all-default only when every value field's effective value equals its schema default.
820 auto is_all_default = [&](const MetatileAttribute &attribute) -> bool {
821 for (const Field &field : schema_->value_fields()) {
822 if (effective_value(attribute, field) != field.default_value()) {
823 return false;
824 }
825 }
826 return true;
827 };
828
829 // Omitting an all-default row is lossless: it reloads as an absent attribute, and downstream consumers (the
830 // compiler in particular) materialize an absent attribute from the schema defaults, so the round trip reproduces
831 // exactly the values the row was omitted for.
832 auto omit_row = [&](const MetatileAttribute &attribute) -> bool { return is_all_default(attribute); };
833
834 if (role_pins.empty()) {
835 // No role pins: byte-identical to the historical output. Skip all-default rows, and if none survive write only
836 // the header.
837 std::size_t non_default_count = 0;
838 for (const auto &attribute : attributes | std::views::values) {
839 if (!omit_row(attribute)) {
840 non_default_count++;
841 }
842 }
843
844 if (non_default_count == 0) {
845 out.flush();
846 return {};
847 }
848
849 for (const auto &[metatile_id, attribute] : attributes) {
850 if (omit_row(attribute)) {
851 continue;
852 }
853 PT_TRY_ASSIGN_PASS_ERR(fields_str, render_fields(attribute, metatile_id), void);
854 out << metatile_id << "," << fields_str << "\n";
855 }
856
857 out.flush();
858 return {};
859 }
860
861 // Renders one pin cell for a role. Only an explicitly pinned value emits a token; an inferred/auto value (the
862 // default, and everything a bin parser or decompiler produces) emits a blank cell. This keeps the "blank = auto"
863 // workflow intact across a load/save round-trip. A row the user left blank carries no explicit value, so it must
864 // not be written back as a pinned token that the next compile would then treat as an override.
865 auto render_pin_cell = [](FieldRole role, const MetatileAttribute &attribute) -> std::string {
866 switch (role) {
868 return attribute.explicit_layer_type().has_value()
869 ? layer_type_csv_token(attribute.explicit_layer_type().value())
870 : std::string{};
871 }
872 panic("write_attributes_csv: unhandled FieldRole in role pin cell rendering");
873 };
874
875 // Renders the trailing pin cells for one attribute, one per role pin in config order, each preceded by a comma.
876 auto render_pin_cells = [&](const MetatileAttribute &attribute) -> std::string {
877 std::string cells{};
878 for (const RolePinDefinition &pin : role_pins) {
879 cells += "," + render_pin_cell(pin.role, attribute);
880 }
881 return cells;
882 };
883
884 // Role pins configured: emit one row per metatile so every metatile has a pin slot the user can fill. The attribute
885 // map can be sparse (tileset creation stores only some ids), so materialize a default row for any missing id. Row
886 // count comes from the Porytiles layer image dimensions, the same source LayerImageMetatileizer uses.
887 const std::size_t layer_metatile_count = metatile::metatile_count(src.porytiles_component().bottom());
888 MetatileAttribute default_attribute{}; // all-default fields, no explicit layer type (blank cell)
889 for (const Field &field : schema_->value_fields()) {
890 default_attribute.field(field.name(), field.default_value());
891 }
892
893 // A schema may carry zero value fields (only the role-bearing field, or nothing at all), in which case the id cell
894 // is followed directly by the pin cells; a bare comma there would add a phantom empty column
895 auto emit_row = [&](std::size_t metatile_id, const std::string &fields_str, const MetatileAttribute &attribute) {
896 out << metatile_id;
897 if (!fields_str.empty()) {
898 out << "," << fields_str;
899 }
900 out << render_pin_cells(attribute) << "\n";
901 };
902
903 for (std::size_t metatile_id = 0; metatile_id < layer_metatile_count; ++metatile_id) {
904 const auto it = attributes.find(metatile_id);
905 const MetatileAttribute &attribute = it != attributes.end() ? it->second : default_attribute;
906
907 PT_TRY_ASSIGN_PASS_ERR(fields_str, render_fields(attribute, metatile_id), void);
908 emit_row(metatile_id, fields_str, attribute);
909 }
910
911 // Inconsistent input: emit any stored ids at or beyond the derived count so no stored attribute is silently
912 // dropped.
913 for (const auto &[metatile_id, attribute] : attributes) {
914 if (metatile_id < layer_metatile_count) {
915 continue;
916 }
917 PT_TRY_ASSIGN_PASS_ERR(fields_str, render_fields(attribute, metatile_id), void);
918 emit_row(metatile_id, fields_str, attribute);
919 }
920
921 out.flush();
922 return {};
923}
924
926 const ArtifactKey &dest_key, const Tileset &src, std::size_t index)
927{
928 if (src.porytiles_component().palette_at(index).has_value()) {
930 transaction_dest_path,
931 compute_transaction_dest_path(
932 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
933 void,
934 "Failed to compute transaction dest path.");
935
936 return save_palette(
937 src.porytiles_component().palette_at(index).value(), transaction_dest_path, *palette_saver_);
938 }
939
940 // No porytiles palette, do nothing
941 return {};
942}
943
945 const ArtifactKey &dest_key, const Tileset &src, const std::string &anim_name, const std::string &frame_name)
946{
947 return write_anim_frame_impl<Rgba32>(
948 dest_key,
949 src,
950 anim_name,
951 frame_name,
952 transaction_root_,
953 project_root_,
954 staged_directories_,
955 staged_special_files_,
956 [](const Tileset &t) -> const auto & { return t.porytiles_component(); },
957 [this](const Image<Rgba32> &img, const std::filesystem::path &path) {
958 return save_layer_png(*png_rgba_saver_, img, path);
959 },
960 "Porytiles");
961}
962
963[[nodiscard]] ChainableResult<void>
965{
966 const auto &porytiles_anims = src.porytiles_component().anims();
967 const auto &primary_overrides = src.porytiles_component().primary_anim_overrides();
968
969 if (porytiles_anims.empty() && primary_overrides.empty()) {
970 // Unlike in write_porymap_anim_params, we don't need to delete anything here. That's because anim.json is
971 // within porytiles_src dir, which is written using an atomic move. If the new porytiles_src dir doesn't contain
972 // an anim.json, the old one will get wiped by the commit() call.
973 return {};
974 }
975
976 // Extract params from animations
977 std::map<DynamicCasedName, AnimParams> anim_params;
978 for (const auto &[anim_name, anim] : porytiles_anims) {
979 anim_params[DynamicCasedName{anim_name}] = anim.params();
980 }
981
982 // Convert primary_anim_overrides keys from std::string to DynamicCasedName
983 std::map<DynamicCasedName, std::vector<AnimOverrideEntry>> primary_refs;
984 for (const auto &[name, entries] : primary_overrides) {
985 primary_refs[DynamicCasedName{name}] = entries;
986 }
987
989 transaction_dest_path,
990 compute_transaction_dest_path(
991 transaction_root_, project_root_, dest_key, staged_directories_, staged_special_files_),
992 void,
993 "Failed to compute transaction dest path.");
994
995 return anim_json_parser_->write(transaction_dest_path, anim_params, primary_refs);
996}
997
998} // 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_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_CHAIN_ERR(expr, return_type,...)
Unwraps a void ChainableResult, chaining a new error message on failure.
ChainableResult< std::string > generate(const std::string &tileset_name, const std::filesystem::path &tileset_path_from_project_root, const std::map< DynamicCasedName, AnimParams > &animations, bool is_primary) const
Generates the complete generated_anim_code.h content.
Represents a single frame of an animation, containing tiles and a frame name.
const Palette< Rgba32 > & palette() const
bool has_palette() const
const std::vector< PixelTile< PixelType > > & tiles() const
ChainableResult< void > write(const std::filesystem::path &json_path, const std::map< DynamicCasedName, AnimParams > &params, const std::map< DynamicCasedName, std::vector< AnimOverrideEntry > > &primary_references={}) const
Writes animation parameters to an anim.json file.
A type-safe wrapper for artifact keys.
const std::string & key() const
A result type that maintains a chainable sequence of errors for debugging and error reporting.
ChainableResult< ConfigValue< TilesPaletteMode > > tiles_palette_mode(ConfigScopeType type, const std::string &scope) const
A smart string wrapper that preserves word structure for lossless case format conversion.
std::string to_snake_case() const
Outputs all words flattened and joined with underscores.
One named bit-field within a metatile attribute layout.
A service interface that saves a fixed-length Palette to a given file.
virtual ChainableResult< void > save(const Palette< Rgba32, palette::max_size > &palette, const std::filesystem::path &path) const =0
A text parameter with associated styling for formatted output.
General-purpose error implementation with formatted message support.
Definition error.hpp:57
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
ChainableResult< ConfigValue< std::string > > tileset_paths_secondary_bin(ConfigScopeType type, const std::string &scope) const
ChainableResult< ConfigValue< std::string > > tileset_paths_primary_bin(ConfigScopeType type, const std::string &scope) const
ChainableResult< ConfigValue< RolePinDefinitions > > role_pins(ConfigScopeType type, const std::string &scope) const
The attributes of a single metatile, modeled as a map of named field values.
const std::map< std::string, std::uint32_t, std::less<> > & fields() const
const std::optional< LayerType > & explicit_layer_type() const
Returns the explicit (user-pinned) layer type, if one was set.
std::uint32_t field(std::string_view field_name) const
Returns the value of a named field, or 0 if the field is absent.
A generic palette container for colors that support transparency checking.
Definition palette.hpp:45
std::size_t size() const
Returns the number of slots in the palette.
Definition palette.hpp:203
ColorType at(std::size_t index) const
Gets the color at a specific index.
Definition palette.hpp:246
bool has_any_wildcards() const
Checks if the palette contains any wildcard slots.
Definition palette.hpp:187
An 8x8 tile backed by literal-array-based per-pixel storage of an arbitrary pixel type.
An image saver that saves PNG files from an Image with an index pixel type.
virtual ChainableResult< void > save_to_file(const Image< IndexPixel > &image, const std::filesystem::path &path, TilesPaletteMode mode) const
An image saver that saves PNG files from an Image with an Rgba32 pixel type.
virtual ChainableResult< void > save_to_file(const Image< Rgba32 > &image, const std::filesystem::path &path) const
const std::map< std::string, Animation< IndexPixel > > & anims() const
const std::vector< TilemapEntry > & metatiles_bin() const
const Palette< Rgba32, palette::max_size > & palette_at(std::size_t palette_index) const
const std::vector< MetatileAttribute > & metatile_attributes_bin() const
const Image< IndexPixel > & tiles_png() const
const std::optional< Palette< Rgba32, palette::max_size > > & palette_at(std::size_t palette_index) const
const std::map< std::string, Animation< Rgba32 > > & anims() const
const std::map< std::size_t, MetatileAttribute > & metatile_attributes() const
const std::map< std::string, std::vector< AnimOverrideEntry > > & primary_anim_overrides() const
ChainableResult< void > write_porymap_anim_params(const ArtifactKey &dest_key, const Tileset &src) override
Writes the animation parameters to the Porymap component backing store.
ChainableResult< void > write_metatiles_bin(const ArtifactKey &dest_key, const Tileset &src) override
ChainableResult< void > write_top_png(const ArtifactKey &dest_key, const Tileset &src) override
ChainableResult< void > begin_transaction() override
Begins a new transaction for atomic write operations.
ChainableResult< void > write_porytiles_anim_params(const ArtifactKey &dest_key, const Tileset &src) override
Writes animation parameters to the Porytiles component backing store.
ChainableResult< void > write_tiles_png(const ArtifactKey &dest_key, const Tileset &src) override
ChainableResult< void > write_porytiles_palette_n(const ArtifactKey &dest_key, const Tileset &src, std::size_t index) override
ChainableResult< void > write_bottom_png(const ArtifactKey &dest_key, const Tileset &src) override
ChainableResult< void > commit() override
Commits all buffered write operations in the current transaction.
ChainableResult< void > write_middle_png(const ArtifactKey &dest_key, const Tileset &src) override
ChainableResult< void > write_porymap_anim_frame(const ArtifactKey &dest_key, const Tileset &src, const std::string &anim_name, const std::string &frame_name) override
ChainableResult< void > write_metatile_attributes_bin(const ArtifactKey &dest_key, const Tileset &src) override
ChainableResult< void > rollback() override
Rolls back all buffered write operations in the current transaction.
ChainableResult< void > write_porymap_palette_n(const ArtifactKey &dest_key, const Tileset &src, std::size_t index) override
ChainableResult< void > write_porytiles_anim_frame(const ArtifactKey &dest_key, const Tileset &src, const std::string &anim_name, const std::string &frame_name) override
ChainableResult< void > write_attributes_csv(const ArtifactKey &dest_key, const Tileset &src) override
ChainableResult< bool > is_secondary(const std::string &tileset_name) const override
Determines whether a tileset is a secondary tileset.
const std::vector< Field > & value_fields() const
Returns the fields that hold plain per-metatile values, excluding the layer_type-role field.
static const Style bold
Bold text formatting.
A complete tileset containing both Porytiles and Porymap components.
Definition tileset.hpp:12
const PorytilesTilesetComponent & porytiles_component() const
Definition tileset.hpp:35
const PorymapTilesetComponent & porymap_component() const
Definition tileset.hpp:45
const std::string & name() const
Definition tileset.hpp:30
std::string name
std::size_t metatile_count(const Image< PixelType > &layer)
Returns how many metatiles a single layer image holds.
Definition metatile.hpp:40
constexpr std::size_t side_length_pix
std::string pin_column_name(FieldRole role)
Returns the one legal attributes.csv column name for a role's pin column.
ChainableResult< void > save_metatile_attributes_bin(const std::vector< MetatileAttribute > &attributes, const std::filesystem::path &path, const Schema &schema)
Writes metatile attributes to a metatile_attributes.bin file according to a schema.
std::vector< RolePinDefinition > RolePinDefinitions
An ordered list of role pin definitions; order is display/declaration order (also CSV column order).
void panic(const StringViewSourceLoc &s)
Unconditionally terminates the program with a panic message.
Definition panic.cpp:43
std::string layer_type_csv_token(LayerType layer_type)
Converts a LayerType to its lowercase CSV token.
Definition layer.hpp:128
TilesPaletteMode
Controls how tiles.png is rendered.
DynamicCasedName extract_tileset_cased_name(const std::string &tileset_name)
Extracts the tileset short name and wraps it in a DynamicCasedName.
FieldRole
Semantic roles a schema field can carry beyond holding a plain per-metatile value.
@ tileset
Configuration scoped to a specific tileset.
Utility functions for string manipulation and formatting.
A user request to emit a trailing pin column for one schema role in attributes.csv.