yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
object_tile_editor_panel.cc
Go to the documentation of this file.
2#include "util/i18n/tr.h"
3
4#include <algorithm>
5
6#include "absl/strings/str_format.h"
10#include "core/features.h"
11#include "imgui/imgui.h"
13
14namespace yaze {
15namespace editor {
16
17namespace {
18
19void MixFingerprint(uint64_t* fingerprint, uint64_t value) {
20 constexpr uint64_t kFnvPrime = 1099511628211ULL;
21 *fingerprint ^= value;
22 *fingerprint *= kFnvPrime;
23}
24
25bool PathsReferToSameAsset(const std::filesystem::path& lhs,
26 const std::filesystem::path& rhs) {
27 if (lhs == rhs) {
28 return true;
29 }
30 std::error_code error;
31 const bool equivalent = std::filesystem::equivalent(lhs, rhs, error);
32 return !error && equivalent;
33}
34
35} // namespace
36
38 : renderer_(renderer), rom_(rom) {
39 tile_editor_ = std::make_unique<zelda3::ObjectTileEditor>(rom);
40}
41
45
52
57
59 std::string message) {
61 action_status_message_ = std::move(message);
62}
63
80
81absl::Status ObjectTileEditorPanel::OpenForObject(int16_t object_id,
82 int room_id,
83 DungeonRoomStore* rooms) {
84 return OpenForObject(object_id, room_id, rooms, current_palette_group_);
85}
86
88 int16_t object_id, int room_id, DungeonRoomStore* rooms,
89 const gfx::PaletteGroup& palette_group) {
91 return absl::UnimplementedError(
92 "Standard object tile editing is supported only for audited objects "
93 "0x11F and 0x120");
94 }
95 if (rooms == nullptr) {
96 return absl::InvalidArgumentError(
97 "A dungeon room store is required to edit object tiles");
98 }
99 if (room_id < 0 || room_id >= static_cast<int>(rooms->size())) {
100 return absl::OutOfRangeError("Dungeon room id is outside the room store");
101 }
103 return absl::FailedPreconditionError(
104 "Apply, revert, or close the current Object Tile Editor session "
105 "before opening another object");
106 }
107
108 auto& room = (*rooms)[room_id];
109 auto layout_or =
110 tile_editor_->CaptureEditableObjectLayout(object_id, room, palette_group);
111 if (!layout_or.ok()) {
112 return layout_or.status();
113 }
114
115 // Do not disturb a live editor session until every fail-closed capture check
116 // has passed. In particular, a bad selection must not discard unsaved edits.
117 current_object_id_ = object_id;
118 current_room_id_ = room_id;
119 rooms_ = rooms;
120 current_palette_group_ = palette_group;
121 current_layout_ = std::move(*layout_or);
123 is_open_ = true;
125 return absl::OkStatus();
126}
127
129 int16_t object_id, int subtype, int room_id, DungeonRoomStore* rooms) {
130 return OpenForCustomObject(object_id, subtype, room_id, rooms,
132}
133
135 int16_t object_id, int subtype, int room_id, DungeonRoomStore* rooms,
136 const gfx::PaletteGroup& palette_group) {
137 if (rooms == nullptr) {
138 return absl::InvalidArgumentError(
139 "A dungeon room store is required to edit custom object tiles");
140 }
141 if (room_id < 0 || room_id >= static_cast<int>(rooms->size())) {
142 return absl::OutOfRangeError("Dungeon room id is outside the room store");
143 }
144 if (!core::FeatureFlags::get().kEnableCustomObjects) {
145 return absl::FailedPreconditionError(
146 "Custom Objects is disabled for the current project");
147 }
148 if (rooms->GetIfLoaded(room_id) == nullptr) {
149 return absl::FailedPreconditionError(
150 "Load the current dungeon room before editing custom object graphics");
151 }
153 return absl::FailedPreconditionError(
154 "Apply, revert, or explicitly discard the current Object Tile Editor "
155 "session before opening another custom object");
156 }
157
158 auto layout_or = tile_editor_->LoadCustomObjectLayout(object_id, subtype);
159 if (!layout_or.ok()) {
160 return layout_or.status();
161 }
162
163 current_object_id_ = object_id;
164 current_room_id_ = room_id;
165 rooms_ = rooms;
166 current_palette_group_ = palette_group;
167 current_layout_ = std::move(*layout_or);
169 is_open_ = true;
171 return absl::OkStatus();
172}
173
175 is_open_ = false;
176 current_layout_ = {};
177 current_room_id_ = -1;
179 rooms_ = nullptr;
181}
182
184 // Hiding a modified editor window must not silently throw away tile edits.
185 // The session remains available when the workspace window is reopened; the
186 // explicit action-bar close remains the user's discard action.
188 Close();
189 }
190}
191
198
200 int room_id, const gfx::PaletteGroup& group) {
201 if (is_open_ && current_room_id_ != room_id) {
202 return;
203 }
205}
206
209 return absl::StrFormat(ICON_MD_GRID_ON " Custom 0x%03X:%02X - %s",
212 }
213
214 return absl::StrFormat(ICON_MD_GRID_ON " Object 0x%03X - %s",
217}
218
230
233 return absl::FailedPreconditionError(
234 "Only an open custom-object session can add its first tile");
235 }
236 if (!current_layout_.cells.empty()) {
237 return absl::FailedPreconditionError(
238 "The custom-object layout already contains tiles");
239 }
240
242 cell.rel_x = 0;
243 cell.rel_y = 0;
244 cell.tile_info = gfx::TileInfo(/*id=*/0, /*palette=*/2, false, false, false);
245 cell.original_word = 0;
246 cell.write_index = 0;
247 cell.modified = true;
248 current_layout_.cells.push_back(cell);
252 preview_dirty_ = true;
253 atlas_dirty_ = true;
255 return absl::OkStatus();
256}
257
259 const bool added_first_tile_to_empty_asset =
261 current_layout_.custom_source_bytes == std::vector<uint8_t>({0, 0}) &&
262 current_layout_.cells.size() == 1 &&
263 current_layout_.cells.front().original_word == 0;
264 if (added_first_tile_to_empty_asset) {
265 current_layout_.cells.clear();
269 } else {
272 }
273 preview_dirty_ = true;
275}
276
277absl::StatusOr<ObjectTileEditorPanel::SourceImpactSnapshot>
280 uint64_t fingerprint = 1469598103934665603ULL;
281 MixFingerprint(&fingerprint,
282 static_cast<uint64_t>(shared_tile_data_usage_override_));
284 }
285
288 return absl::FailedPreconditionError(
289 "Custom object source-impact analysis requires its resolved asset "
290 "path");
291 }
292
293 auto& manager = zelda3::CustomObjectManager::Get();
294 uint64_t fingerprint = 1469598103934665603ULL;
295 MixFingerprint(&fingerprint, manager.asset_generation());
296 int consumer_count = 0;
297 std::vector<int> mapped_object_ids(
300 const auto manager_state = manager.SnapshotState();
301 for (const auto& mapping : manager_state.custom_file_map) {
302 const int object_id = mapping.first;
303 if (std::find(mapped_object_ids.begin(), mapped_object_ids.end(),
304 object_id) == mapped_object_ids.end()) {
305 mapped_object_ids.push_back(object_id);
306 }
307 }
308 std::sort(mapped_object_ids.begin(), mapped_object_ids.end());
309
310 for (const int object_id : mapped_object_ids) {
311 const int subtype_count = manager.GetSubtypeCount(object_id);
312 for (int subtype = 0; subtype < subtype_count; ++subtype) {
313 const std::string filename =
314 manager.ResolveFilename(object_id, subtype);
315 if (filename.empty()) {
316 continue;
317 }
318 auto resolved_or = zelda3::ResolveCustomObjectAssetPath(
319 manager.GetBasePath(), filename);
320 if (!resolved_or.ok()) {
321 return absl::FailedPreconditionError(absl::StrFormat(
322 "Could not resolve custom runtime slot 0x%02X:%02X: %s",
323 object_id, subtype, resolved_or.status().message()));
324 }
325 if (!PathsReferToSameAsset(*resolved_or,
327 continue;
328 }
329 ++consumer_count;
330 MixFingerprint(&fingerprint, static_cast<uint16_t>(object_id));
331 MixFingerprint(&fingerprint, static_cast<uint16_t>(subtype));
332 }
333 }
334 if (consumer_count == 0) {
335 return absl::AbortedError(
336 "Custom object mapping or project folder changed after this asset "
337 "was opened; edits were kept");
338 }
339 return SourceImpactSnapshot{consumer_count, fingerprint};
340 }
341 if (current_object_id_ < 0 ||
343 return absl::FailedPreconditionError(
344 "No matching standard object is selected for source-impact analysis");
345 }
346 if (rom_ == nullptr || !rom_->is_loaded()) {
347 return absl::FailedPreconditionError(
348 "A loaded ROM is required for source-impact analysis");
349 }
350
351 auto impact_or =
352 tile_editor_->AnalyzeStandardTileSourceImpact(current_layout_);
353 if (!impact_or.ok()) {
354 return impact_or.status();
355 }
356
357 uint64_t fingerprint = 1469598103934665603ULL;
358 MixFingerprint(&fingerprint, impact_or->affected_objects.size());
359 for (const auto& entry : impact_or->affected_objects) {
360 MixFingerprint(&fingerprint, 0xA11EC7EDULL);
361 MixFingerprint(&fingerprint, static_cast<uint16_t>(entry.object_id));
362 MixFingerprint(&fingerprint, entry.overlapping_ranges.size());
363 for (const auto& range : entry.overlapping_ranges) {
364 MixFingerprint(&fingerprint, range.begin);
365 MixFingerprint(&fingerprint, range.end);
366 }
367 }
368 MixFingerprint(&fingerprint, impact_or->runtime_consumers.size());
369 for (const auto& entry : impact_or->runtime_consumers) {
370 MixFingerprint(&fingerprint, 0x52754E71ULL);
371 MixFingerprint(&fingerprint, static_cast<uint64_t>(entry.consumer));
372 MixFingerprint(&fingerprint, entry.overlapping_ranges.size());
373 for (const auto& range : entry.overlapping_ranges) {
374 MixFingerprint(&fingerprint, range.begin);
375 MixFingerprint(&fingerprint, range.end);
376 }
377 }
378 return SourceImpactSnapshot{static_cast<int>(impact_or->consumer_count()),
379 fingerprint};
380}
381
383 auto snapshot_or = AnalyzeSourceImpactSnapshot();
384 if (!snapshot_or.ok()) {
385 return snapshot_or.status();
386 }
387 return snapshot_or->consumer_count;
388}
389
391 uint64_t fingerprint = 1469598103934665603ULL;
392 MixFingerprint(&fingerprint,
393 static_cast<uint16_t>(current_layout_.object_id));
394 MixFingerprint(&fingerprint,
395 static_cast<uint32_t>(current_layout_.tile_data_address));
396 MixFingerprint(&fingerprint, current_layout_.is_custom ? 1 : 0);
398 MixFingerprint(&fingerprint,
399 static_cast<uint32_t>(current_layout_.custom_subtype));
400 MixFingerprint(&fingerprint,
401 zelda3::CustomObjectManager::Get().asset_generation());
402 for (const unsigned char character :
403 current_layout_.custom_resolved_path.generic_string()) {
404 MixFingerprint(&fingerprint, character);
405 }
406 }
407 MixFingerprint(&fingerprint,
408 static_cast<uint32_t>(current_layout_.bounds_width));
409 MixFingerprint(&fingerprint,
410 static_cast<uint32_t>(current_layout_.bounds_height));
411 MixFingerprint(&fingerprint, current_layout_.cells.size());
412 for (const auto& cell : current_layout_.cells) {
413 MixFingerprint(&fingerprint, static_cast<uint32_t>(cell.rel_x));
414 MixFingerprint(&fingerprint, static_cast<uint32_t>(cell.rel_y));
415 MixFingerprint(&fingerprint, cell.original_word);
416 MixFingerprint(&fingerprint, cell.source_ref.has_value() ? 1 : 0);
417 if (cell.source_ref.has_value()) {
418 MixFingerprint(&fingerprint, cell.source_ref->span_index);
419 MixFingerprint(&fingerprint, cell.source_ref->word_index);
420 }
421 }
422 MixFingerprint(&fingerprint,
423 current_layout_.source_provenance.has_value() ? 1 : 0);
424 if (current_layout_.source_provenance.has_value()) {
425 const auto& provenance = *current_layout_.source_provenance;
426 MixFingerprint(&fingerprint, static_cast<uint16_t>(provenance.object_id));
427 MixFingerprint(&fingerprint, provenance.descriptor_pc_address);
428 MixFingerprint(&fingerprint, provenance.expected_descriptor_word);
429 MixFingerprint(&fingerprint, provenance.spans.size());
430 for (const auto& span : provenance.spans) {
431 MixFingerprint(&fingerprint, span.pc_address);
432 MixFingerprint(&fingerprint, span.expected_words.size());
433 for (const uint16_t word : span.expected_words) {
434 MixFingerprint(&fingerprint, word);
435 }
436 }
437 }
438 return fingerprint;
439}
440
441absl::StatusOr<int>
455
457 auto usage_count_or = GetSharedTileDataUsageCount();
458 if (!usage_count_or.ok()) {
459 return usage_count_or.status();
460 }
461 return *usage_count_or > 1;
462}
463
465 return rooms_ != nullptr &&
466 rooms_->GetIfLoaded(current_room_id_) != nullptr &&
467 !current_layout_.cells.empty();
468}
469
471 preview_dirty_ = true;
472 atlas_dirty_ = true;
473
475 return;
476 }
477
480}
481
482void ObjectTileEditorPanel::Draw(bool* p_open) {
483 if (!is_open_) {
484 if (p_open != nullptr) {
485 *p_open = false;
486 }
487 return;
488 }
489 if (p_open != nullptr && !*p_open) {
490 return;
491 }
492
493 const auto* room =
494 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
495 const uint64_t graphics_revision =
496 room != nullptr ? room->graphics_revision() : 0;
497 if (graphics_revision != room_graphics_revision_) {
498 room_graphics_revision_ = graphics_revision;
499 preview_dirty_ = true;
500 atlas_dirty_ = true;
501 }
502
503 const std::string session_title = BuildWindowTitle();
504 ImGui::TextUnformatted(session_title.c_str());
505 ImGui::TextDisabled(tr("Room 0x%03X"), current_room_id_);
506 ImGui::Separator();
507
508 if (current_layout_.cells.empty()) {
510 if (p_open != nullptr) {
511 *p_open = false;
512 }
513 return;
514 }
515 const auto& theme = gui::ThemeManager::Get().GetCurrentTheme();
516 ImGui::TextColored(gui::ConvertColorToImVec4(theme.warning),
517 ICON_MD_VISIBILITY_OFF " Empty runtime asset");
518 ImGui::TextWrapped(
519 "%s", tr("This fixed slot intentionally draws nothing. Add its first "
520 "tile to begin a graphics override."));
521 if (ImGui::Button(ICON_MD_ADD " Add First Tile")) {
522 const absl::Status status = AddFirstTileToEmptyCustomLayout();
523 if (!status.ok()) {
525 std::string(status.message()));
526 }
527 }
528 ImGui::Separator();
529 DrawActionBar(p_open);
530 return;
531 }
532
533 // Two-column layout: tile grid + source sheet
534 if (ImGui::BeginTable(
535 "##TileEditorLayout", 2,
536 ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV)) {
537 ImGui::TableSetupColumn("Tile Grid", ImGuiTableColumnFlags_WidthFixed,
538 280.0f);
539 ImGui::TableSetupColumn("Source Sheet", ImGuiTableColumnFlags_WidthStretch);
540
541 ImGui::TableNextRow();
542 ImGui::TableNextColumn();
543 DrawTileGrid();
544
545 ImGui::TableNextColumn();
547
548 ImGui::EndTable();
549 }
550
551 ImGui::Separator();
553 ImGui::Separator();
554 DrawActionBar(p_open);
555
557
558 // Shared ROM data or project source-asset confirmation modal.
559 const char* shared_popup_title =
561 ? "Shared Custom Asset###SharedTileDataGlobalEdit"
562 : "Shared Tile Data (Global Edit)###SharedTileDataGlobalEdit";
564 ImGui::OpenPopup(shared_popup_title);
565 show_shared_confirm_ = false;
566 }
567 if (ImGui::BeginPopupModal(shared_popup_title, nullptr,
568 ImGuiWindowFlags_AlwaysAutoResize)) {
570 ImGui::Text(tr("This source asset is used by %d runtime slots."),
572 ImGui::Text(tr("Publishing will update all of those slots."));
573 ImGui::TextWrapped(
574 tr("This project source-asset edit is not covered by Dungeon Editor "
575 "Ctrl+Z."));
576 } else {
577 ImGui::Text(tr("This tile data is shared by %d consumers."),
579 ImGui::Text(tr("Changes will affect all of them."));
580 ImGui::TextWrapped(
581 tr("This global ROM tile edit is not covered by Dungeon Editor "
582 "Ctrl+Z."));
583 }
584 ImGui::Spacing();
585 const char* apply_label = current_layout_.is_custom
586 ? tr("Publish Shared Asset")
587 : tr("Apply Global Edit");
588 if (ImGui::Button(apply_label, ImVec2(170, 0))) {
589 ApplyChanges(/*confirm_shared=*/false);
590 ImGui::CloseCurrentPopup();
591 }
592 ImGui::SameLine();
593 if (ImGui::Button(tr("Cancel"), ImVec2(120, 0))) {
596 ImGui::CloseCurrentPopup();
597 }
598 ImGui::EndPopup();
599 }
600}
601
603 auto* room =
604 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
605 if (room == nullptr) {
608 return;
609 }
610
611 auto status = tile_editor_->RenderLayoutToBitmap(
612 current_layout_, object_preview_bmp_, room->get_gfx_buffer().data(),
614 if (status.ok()) {
616 preview_dirty_ = false;
617 } else {
620 }
621}
622
624 auto* room =
625 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
626 if (room == nullptr) {
629 return;
630 }
631
632 auto status = tile_editor_->BuildTile8Atlas(
633 tile8_atlas_bmp_, room->get_gfx_buffer().data(), current_palette_group_,
636 ? std::optional<int16_t>(current_layout_.object_id)
637 : std::nullopt,
639 if (status.ok()) {
641 atlas_dirty_ = false;
642 } else {
645 }
646}
647
649 if (selected_cell_index_ < 0 ||
650 selected_cell_index_ >= static_cast<int>(current_layout_.cells.size())) {
652 return;
653 }
654
655 const auto& cell = current_layout_.cells[selected_cell_index_];
656 selected_source_tile_ = static_cast<int>(cell.tile_info.id_);
657
658 const int cell_palette = static_cast<int>(cell.tile_info.palette_);
659 if (source_palette_ != cell_palette) {
660 source_palette_ = cell_palette;
661 atlas_dirty_ = true;
662 }
664}
665
667 uint16_t attributes = 0;
669 selected_cell_index_ < static_cast<int>(current_layout_.cells.size())) {
670 attributes = gfx::TileInfoToWord(
672 0xE000;
673 }
674 if (source_attributes_ != attributes) {
675 source_attributes_ = attributes;
676 atlas_dirty_ = true;
677 }
678}
679
681 if (preview_dirty_) {
683 }
684
685 ImGui::Text(tr("Object Tiles (%dx%d)"), current_layout_.bounds_width,
687
688 constexpr float kScale = 4.0f;
689 float grid_width = current_layout_.bounds_width * 8 * kScale;
690 float grid_height = current_layout_.bounds_height * 8 * kScale;
691
692 ImVec2 canvas_pos = ImGui::GetCursorScreenPos();
693 ImVec2 canvas_size(grid_width, grid_height);
694
695 // Draw the preview bitmap as background
697 ImGui::Image((ImTextureID)(intptr_t)object_preview_bmp_.texture(),
698 canvas_size);
699 } else {
700 ImGui::Dummy(canvas_size);
701 }
702
703 ImDrawList* draw_list = ImGui::GetWindowDrawList();
704
705 // Draw 8px grid overlay
706 for (int gx = 0; gx <= current_layout_.bounds_width; ++gx) {
707 float line_x = canvas_pos.x + gx * 8 * kScale;
708 draw_list->AddLine(ImVec2(line_x, canvas_pos.y),
709 ImVec2(line_x, canvas_pos.y + grid_height),
710 IM_COL32(128, 128, 128, 80));
711 }
712 for (int gy = 0; gy <= current_layout_.bounds_height; ++gy) {
713 float line_y = canvas_pos.y + gy * 8 * kScale;
714 draw_list->AddLine(ImVec2(canvas_pos.x, line_y),
715 ImVec2(canvas_pos.x + grid_width, line_y),
716 IM_COL32(128, 128, 128, 80));
717 }
718
719 // Highlight selected cell
720 if (selected_cell_index_ >= 0 &&
721 selected_cell_index_ < static_cast<int>(current_layout_.cells.size())) {
722 const auto& cell = current_layout_.cells[selected_cell_index_];
723 ImVec2 cell_min(canvas_pos.x + cell.rel_x * 8 * kScale,
724 canvas_pos.y + cell.rel_y * 8 * kScale);
725 ImVec2 cell_max(cell_min.x + 8 * kScale, cell_min.y + 8 * kScale);
726 draw_list->AddRect(cell_min, cell_max, IM_COL32(255, 255, 0, 255), 0, 0,
727 2.0f);
728 }
729
730 // Handle clicks on the grid
731 if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
732 ImVec2 mouse = ImGui::GetMousePos();
733 int click_tile_x =
734 static_cast<int>((mouse.x - canvas_pos.x) / (8 * kScale));
735 int click_tile_y =
736 static_cast<int>((mouse.y - canvas_pos.y) / (8 * kScale));
737
738 // Find the cell at this position
739 for (int idx = 0; idx < static_cast<int>(current_layout_.cells.size());
740 ++idx) {
741 if (current_layout_.cells[idx].rel_x == click_tile_x &&
742 current_layout_.cells[idx].rel_y == click_tile_y) {
745 break;
746 }
747 }
748 }
749
750 // Show cell count
751 int modified_count = 0;
752 for (const auto& cell : current_layout_.cells) {
753 if (cell.modified)
754 ++modified_count;
755 }
756 ImGui::Text(tr("%zu tiles, %d modified"), current_layout_.cells.size(),
757 modified_count);
758}
759
761 if (atlas_dirty_) {
763 }
764
765 ImGui::Text(tr("Source Tiles (Palette %d)"), source_palette_);
766
767 // Palette selector
768 ImGui::SameLine();
769 ImGui::SetNextItemWidth(80);
770 if (ImGui::SliderInt("##SrcPal", &source_palette_, 0, 7)) {
771 atlas_dirty_ = true;
772 }
773
774 constexpr float kAtlasScale = 2.0f;
775 float display_width = zelda3::ObjectTileEditor::kAtlasWidthPx * kAtlasScale;
776 float display_height = zelda3::ObjectTileEditor::kAtlasHeightPx * kAtlasScale;
777
778 ImVec2 atlas_pos = ImGui::GetCursorScreenPos();
779 ImVec2 atlas_size(display_width, display_height);
780
781 // Scrollable child for the atlas
782 ImGui::BeginChild("##AtlasScroll", ImVec2(display_width + 16, 300), true,
783 ImGuiWindowFlags_HorizontalScrollbar);
784
785 atlas_pos = ImGui::GetCursorScreenPos();
786
788 ImGui::Image((ImTextureID)(intptr_t)tile8_atlas_bmp_.texture(), atlas_size);
789 } else {
790 ImGui::Dummy(atlas_size);
791 }
792
793 ImDrawList* draw_list = ImGui::GetWindowDrawList();
794
795 // Draw 8px grid
796 for (int gx = 0; gx <= zelda3::ObjectTileEditor::kAtlasTilesPerRow; ++gx) {
797 float line_x = atlas_pos.x + gx * 8 * kAtlasScale;
798 draw_list->AddLine(ImVec2(line_x, atlas_pos.y),
799 ImVec2(line_x, atlas_pos.y + display_height),
800 IM_COL32(64, 64, 64, 60));
801 }
802 for (int gy = 0; gy <= zelda3::ObjectTileEditor::kAtlasTileRows; ++gy) {
803 float line_y = atlas_pos.y + gy * 8 * kAtlasScale;
804 draw_list->AddLine(ImVec2(atlas_pos.x, line_y),
805 ImVec2(atlas_pos.x + display_width, line_y),
806 IM_COL32(64, 64, 64, 60));
807 }
808
809 // Highlight selected source tile
810 if (selected_source_tile_ >= 0) {
811 int src_col =
813 int src_row =
815 ImVec2 sel_min(atlas_pos.x + src_col * 8 * kAtlasScale,
816 atlas_pos.y + src_row * 8 * kAtlasScale);
817 ImVec2 sel_max(sel_min.x + 8 * kAtlasScale, sel_min.y + 8 * kAtlasScale);
818 draw_list->AddRect(sel_min, sel_max, IM_COL32(0, 255, 255, 255), 0, 0,
819 2.0f);
820 }
821
822 // Handle clicks on the atlas
823 if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
824 ImVec2 mouse = ImGui::GetMousePos();
825 int click_col =
826 static_cast<int>((mouse.x - atlas_pos.x) / (8 * kAtlasScale));
827 int click_row =
828 static_cast<int>((mouse.y - atlas_pos.y) / (8 * kAtlasScale));
829
830 if (click_col >= 0 &&
832 click_row >= 0 &&
834 int tile_id =
835 click_row * zelda3::ObjectTileEditor::kAtlasTilesPerRow + click_col;
836 selected_source_tile_ = tile_id;
837
838 // If a cell is selected, replace its tile
839 if (selected_cell_index_ >= 0 &&
841 static_cast<int>(current_layout_.cells.size())) {
843 cell.tile_info.id_ = static_cast<uint16_t>(tile_id);
844 cell.tile_info.palette_ = static_cast<uint8_t>(source_palette_);
845 cell.modified = true;
846 preview_dirty_ = true;
848 }
849 }
850 }
851
852 ImGui::EndChild();
853
854 if (selected_source_tile_ >= 0) {
855 ImGui::Text(tr("Tile: 0x%03X"), selected_source_tile_);
856 }
857}
858
860 if (selected_cell_index_ < 0 ||
861 selected_cell_index_ >= static_cast<int>(current_layout_.cells.size())) {
862 ImGui::TextDisabled(tr("Select a tile cell to edit properties"));
863 return;
864 }
865
867 ImGui::Text(tr("Cell (%d, %d)"), cell.rel_x, cell.rel_y);
868 ImGui::SameLine();
869
870 // Tile ID
871 int tile_id = cell.tile_info.id_;
872 ImGui::SetNextItemWidth(80);
873 if (ImGui::InputInt(tr("ID"), &tile_id, 1, 16)) {
874 cell.tile_info.id_ = static_cast<uint16_t>(tile_id & 0x3FF);
875 cell.modified = true;
876 preview_dirty_ = true;
879 }
880 ImGui::SameLine();
881
882 // Palette
883 int pal = cell.tile_info.palette_;
884 ImGui::SetNextItemWidth(60);
885 if (ImGui::SliderInt(tr("Pal"), &pal, 0, 7)) {
886 cell.tile_info.palette_ = static_cast<uint8_t>(pal);
887 cell.modified = true;
888 preview_dirty_ = true;
891 }
892 ImGui::SameLine();
893
894 // Flip flags
895 if (ImGui::Checkbox(tr("H"), &cell.tile_info.horizontal_mirror_)) {
896 cell.modified = true;
897 preview_dirty_ = true;
900 }
901 ImGui::SameLine();
902 if (ImGui::Checkbox(tr("V"), &cell.tile_info.vertical_mirror_)) {
903 cell.modified = true;
904 preview_dirty_ = true;
907 }
908 ImGui::SameLine();
909 if (ImGui::Checkbox(tr("Pri"), &cell.tile_info.over_)) {
910 cell.modified = true;
911 preview_dirty_ = true;
914 }
915}
916
919 return tile_editor_->WriteBack(current_layout_);
920 }
921
922 auto plan_or = tile_editor_->BuildStandardWritePlan(current_layout_);
923 if (!plan_or.ok()) {
924 return plan_or.status();
925 }
926
927 const auto& plan = *plan_or;
928 if (standard_write_preflight_ && !plan.write_ranges().empty()) {
929 const absl::Status preflight =
930 standard_write_preflight_(plan.write_ranges());
931 if (!preflight.ok()) {
932 return preflight;
933 }
934 }
935
936 return tile_editor_->ApplyStandardWritePlan(plan);
937}
938
939void ObjectTileEditorPanel::ApplyChanges(bool confirm_shared) {
940 // Resolve the complete source impact before every write, including
941 // the second call after the user accepts the shared-data confirmation. A
942 // malformed or unresolved object family must block the write rather than be
943 // interpreted as an unshared source.
944 const auto impact_snapshot_or = AnalyzeSourceImpactSnapshot();
945 if (!impact_snapshot_or.ok()) {
946 show_shared_confirm_ = false;
951 absl::StrFormat(ICON_MD_ERROR
952 " Apply blocked: source impact could not be resolved: "
953 "%s",
954 impact_snapshot_or.status().message()));
955 return;
956 }
957 const SourceImpactSnapshot impact_snapshot = *impact_snapshot_or;
958 const int shared_count = impact_snapshot.consumer_count;
959 if (confirm_shared && shared_count > 1) {
960 shared_object_count_ = shared_count;
961 pending_shared_confirmation_ = impact_snapshot;
966 absl::StrFormat(
968 " Confirm shared asset publish: %d runtime slots use this "
969 "file. This project-source edit is not covered by Ctrl+Z.",
970 shared_count));
971 } else {
974 absl::StrFormat(ICON_MD_WARNING
975 " Confirm global apply: %d consumers use this tile "
976 "data. This edit is not covered by Ctrl+Z.",
977 shared_count));
978 }
979 return;
980 }
981 const bool missing_required_confirmation =
982 shared_count > 1 && !pending_shared_confirmation_.has_value();
983 const bool confirmed_impact_changed =
984 pending_shared_confirmation_.has_value() &&
985 *pending_shared_confirmation_ != impact_snapshot;
986 if (!confirm_shared &&
987 (missing_required_confirmation || confirmed_impact_changed)) {
988 shared_object_count_ = shared_count;
989 pending_shared_confirmation_ = impact_snapshot;
994 absl::StrFormat(
996 " Source impact changed; review and confirm the updated "
997 "publish for %d runtime slots. This project-source edit is not "
998 "covered by Ctrl+Z.",
999 shared_count));
1000 } else {
1003 absl::StrFormat(
1005 " Source impact changed; review and confirm the updated global "
1006 "apply for %d consumers. This edit is not covered by Ctrl+Z.",
1007 shared_count));
1008 }
1009 return;
1010 }
1011
1012 show_shared_confirm_ = false;
1015
1016 auto status = WriteBackCurrentLayout();
1017 if (status.ok()) {
1018 if (rooms_ != nullptr) {
1020 [](int, zelda3::Room& room) { room.MarkObjectsDirty(); });
1021 }
1022 if (on_tiles_applied_) {
1024 }
1025
1026 // Re-render the current room immediately. Other materialized rooms were
1027 // marked dirty above and refresh lazily when their canvases draw.
1029 auto& room = (*rooms_)[current_room_id_];
1030 room.MarkObjectsDirty();
1031 room.RenderRoomGraphics();
1032 }
1033
1034 // Update both the cells and the authoritative source snapshot only after
1035 // the write succeeds. The next impact analysis reuses BuildStandardWritePlan
1036 // as its CAS gate, so leaving expected_words stale here would make every
1037 // later apply fail until the panel was reopened.
1038 for (auto& cell : current_layout_.cells) {
1039 if (cell.modified) {
1040 const uint16_t applied_word = gfx::TileInfoToWord(cell.tile_info);
1041 cell.original_word = applied_word;
1043 current_layout_.source_provenance.has_value() &&
1044 cell.source_ref.has_value()) {
1045 const auto& source_ref = *cell.source_ref;
1046 auto& spans = current_layout_.source_provenance->spans;
1047 if (source_ref.span_index < spans.size() &&
1048 source_ref.word_index <
1049 spans[source_ref.span_index].expected_words.size()) {
1050 spans[source_ref.span_index].expected_words[source_ref.word_index] =
1051 applied_word;
1052 }
1053 }
1054 cell.modified = false;
1055 }
1056 }
1057
1059 if (shared_count > 1) {
1063 absl::StrFormat(
1065 " Published shared custom asset used by %d runtime slots.",
1066 shared_count));
1067 } else {
1070 absl::StrFormat(
1072 " Applied changes to shared tile data used by %d consumers.",
1073 shared_count));
1074 }
1075 } else {
1077 }
1078 return;
1079 }
1080
1083 absl::StrFormat(ICON_MD_ERROR " Apply failed: %s", status.message()));
1084}
1085
1087 const auto& theme = gui::ThemeManager::Get().GetCurrentTheme();
1088 int modified_count = 0;
1089 for (const auto& cell : current_layout_.cells) {
1090 if (cell.modified)
1091 ++modified_count;
1092 }
1093
1094 bool has_mods = modified_count > 0;
1095
1096 if (has_mods) {
1097 ImGui::Text(tr("%d tile(s) modified"), modified_count);
1098 ImGui::SameLine();
1099 }
1100
1101 // Shared tile data warning. Keep analysis errors visible here, but enforce
1102 // fail-closed behavior in ApplyChanges so the disabled-by-default standard
1103 // editor cannot silently turn an unknown impact into a write.
1104 const auto shared_count_or = GetDisplayedSharedTileDataUsageCount();
1105 if (!shared_count_or.ok()) {
1106 ImGui::TextColored(gui::ConvertColorToImVec4(theme.error),
1107 ICON_MD_ERROR " Source impact unavailable");
1108 if (ImGui::IsItemHovered()) {
1109 ImGui::SetItemTooltip("%s", shared_count_or.status().message().data());
1110 }
1111 ImGui::SameLine();
1112 } else if (*shared_count_or > 1) {
1113 const int shared_count = *shared_count_or;
1115 ImGui::TextColored(gui::ConvertColorToImVec4(theme.warning),
1116 ICON_MD_WARNING " Shared by %d runtime slots",
1117 shared_count);
1118 } else {
1119 ImGui::TextColored(gui::ConvertColorToImVec4(theme.warning),
1120 ICON_MD_WARNING " Shared by %d consumers",
1121 shared_count);
1122 }
1123 if (ImGui::IsItemHovered()) {
1125 ImGui::SetItemTooltip(
1126 tr("%d fixed runtime slots resolve to this source file.\n"
1127 "Publishing changes updates all of them.\nThis project "
1128 "source-asset edit is not covered by Ctrl+Z."),
1129 shared_count);
1130 } else {
1131 ImGui::SetItemTooltip(
1132 tr("This object reuses tile data with %d consumers.\nApplying "
1133 "changes will update every object or runtime consumer in that "
1134 "shared data group.\nThis global ROM edit is not covered by "
1135 "Ctrl+Z."),
1136 shared_count);
1137 }
1138 }
1139 ImGui::SameLine();
1140 }
1141
1142 // Apply button
1143 if (!has_mods)
1144 ImGui::BeginDisabled();
1145 const char* primary_action_label = current_layout_.is_custom
1146 ? ICON_MD_SAVE " Publish Asset"
1147 : ICON_MD_SAVE " Apply";
1148 if (ImGui::Button(primary_action_label)) {
1149 ApplyChanges();
1150 }
1151 if (!has_mods)
1152 ImGui::EndDisabled();
1153
1154 ImGui::SameLine();
1155
1156 // Revert button
1157 if (!has_mods)
1158 ImGui::BeginDisabled();
1159 if (ImGui::Button(ICON_MD_UNDO " Revert")) {
1161 }
1162 if (!has_mods)
1163 ImGui::EndDisabled();
1164
1165 ImGui::SameLine();
1166
1167 const char* close_label =
1168 has_mods ? ICON_MD_CLOSE " Discard & Close" : ICON_MD_CLOSE " Close";
1169 if (ImGui::Button(close_label)) {
1170 if (p_open != nullptr) {
1171 *p_open = false;
1172 }
1173 Close();
1174 return;
1175 }
1176 if (has_mods && ImGui::IsItemHovered()) {
1177 ImGui::SetItemTooltip(
1178 "%s", tr("Close the panel and discard all unapplied tile changes."));
1179 }
1180
1181 ImGui::Spacing();
1182 ImGui::TextDisabled("%s",
1184 " Project source-asset edit; not covered by Ctrl+Z."
1185 : ICON_MD_INFO
1186 " Global ROM tile edit; not covered by Ctrl+Z.");
1187
1189 !action_status_message_.empty()) {
1190 ImVec4 status_color = gui::ConvertColorToImVec4(theme.text_secondary);
1191 switch (action_status_tone_) {
1193 status_color = gui::ConvertColorToImVec4(theme.warning);
1194 break;
1196 status_color = gui::ConvertColorToImVec4(theme.success);
1197 break;
1199 status_color = gui::ConvertColorToImVec4(theme.error);
1200 break;
1202 break;
1203 }
1204
1205 ImGui::Spacing();
1206 ImGui::PushStyleColor(ImGuiCol_Text, status_color);
1207 ImGui::PushTextWrapPos(0.0f);
1208 ImGui::TextUnformatted(action_status_message_.c_str());
1209 ImGui::PopTextWrapPos();
1210 ImGui::PopStyleColor();
1211 }
1212}
1213
1216 if (p_open != nullptr) {
1217 *p_open = false;
1218 }
1221 " Unapplied tile changes were kept. Reopen the panel to continue, or "
1222 "use Discard & Close to throw them away.");
1223 return;
1224 }
1225
1226 if (p_open != nullptr) {
1227 *p_open = false;
1228 }
1229 Close();
1230}
1231
1233 // Only handle shortcuts when this window is focused
1234 if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows))
1235 return;
1236 if (current_layout_.cells.empty())
1237 return;
1238
1239 int cell_count = static_cast<int>(current_layout_.cells.size());
1240
1241 // Arrow keys: navigate selected cell by spatial position
1242 auto find_neighbor = [&](int dx, int dy) -> int {
1243 if (selected_cell_index_ < 0)
1244 return 0;
1245 const auto& cur = current_layout_.cells[selected_cell_index_];
1246 int target_x = cur.rel_x + dx;
1247 int target_y = cur.rel_y + dy;
1248 for (int i = 0; i < cell_count; ++i) {
1249 if (current_layout_.cells[i].rel_x == target_x &&
1250 current_layout_.cells[i].rel_y == target_y) {
1251 return i;
1252 }
1253 }
1254 return selected_cell_index_;
1255 };
1256
1257 if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) {
1258 selected_cell_index_ = find_neighbor(-1, 0);
1260 }
1261 if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) {
1262 selected_cell_index_ = find_neighbor(1, 0);
1264 }
1265 if (ImGui::IsKeyPressed(ImGuiKey_UpArrow, false)) {
1266 selected_cell_index_ = find_neighbor(0, -1);
1268 }
1269 if (ImGui::IsKeyPressed(ImGuiKey_DownArrow, false)) {
1270 selected_cell_index_ = find_neighbor(0, 1);
1272 }
1273
1274 // Number keys 0-7: set palette on selected cell
1275 if (selected_cell_index_ >= 0 && selected_cell_index_ < cell_count) {
1277 for (int key = 0; key <= 7; ++key) {
1278 if (ImGui::IsKeyPressed(static_cast<ImGuiKey>(ImGuiKey_0 + key), false)) {
1279 cell.tile_info.palette_ = static_cast<uint8_t>(key);
1280 cell.modified = true;
1281 preview_dirty_ = true;
1284 }
1285 }
1286
1287 // H: toggle horizontal flip
1288 if (ImGui::IsKeyPressed(ImGuiKey_H, false)) {
1289 cell.tile_info.horizontal_mirror_ = !cell.tile_info.horizontal_mirror_;
1290 cell.modified = true;
1291 preview_dirty_ = true;
1294 }
1295
1296 // V: toggle vertical flip
1297 if (ImGui::IsKeyPressed(ImGuiKey_V, false)) {
1298 cell.tile_info.vertical_mirror_ = !cell.tile_info.vertical_mirror_;
1299 cell.modified = true;
1300 preview_dirty_ = true;
1303 }
1304
1305 // P: toggle priority
1306 if (ImGui::IsKeyPressed(ImGuiKey_P, false)) {
1307 cell.tile_info.over_ = !cell.tile_info.over_;
1308 cell.modified = true;
1309 preview_dirty_ = true;
1312 }
1313 }
1314
1315 // Escape: deselect or close
1316 if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
1317 if (selected_cell_index_ >= 0) {
1320 } else {
1321 RequestSafeWindowClose(p_open);
1322 }
1323 return;
1324 }
1325
1326 // Tab: cycle to next cell
1327 if (ImGui::IsKeyPressed(ImGuiKey_Tab, false)) {
1328 if (cell_count > 0) {
1329 selected_cell_index_ = (selected_cell_index_ + 1) % cell_count;
1331 }
1332 }
1333}
1334
1335} // namespace editor
1336} // namespace yaze
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:28
uint64_t object_tile_revision() const
Definition rom.h:164
bool is_loaded() const
Definition rom.h:155
static Flags & get()
Definition features.h:119
zelda3::Room * GetIfLoaded(int room_id)
absl::StatusOr< SourceImpactSnapshot > AnalyzeSourceImpactSnapshot() const
void ApplyChanges(bool confirm_shared=true)
void SetCurrentPaletteGroup(const gfx::PaletteGroup &group)
void SetCurrentPaletteGroupForRoom(int room_id, const gfx::PaletteGroup &group)
absl::Status OpenForCustomObject(int16_t object_id, int subtype, int room_id, DungeonRoomStore *rooms)
ObjectTileEditorPanel(gfx::IRenderer *renderer, Rom *rom)
std::unique_ptr< zelda3::ObjectTileEditor > tile_editor_
void OnClose() override
Called when panel is hidden.
absl::StatusOr< bool > HasSharedTileDataConflict() const
std::optional< SourceImpactDisplayCacheKey > source_impact_display_cache_key_
StandardWritePreflightCallback standard_write_preflight_
absl::Status OpenForObject(int16_t object_id, int room_id, DungeonRoomStore *rooms)
std::optional< absl::StatusOr< int > > source_impact_display_cache_result_
void Draw(bool *p_open) override
Draw the panel content.
absl::StatusOr< int > GetDisplayedSharedTileDataUsageCount()
void SetActionStatus(ActionStatusTone tone, std::string message)
absl::StatusOr< int > GetSharedTileDataUsageCount() const
std::optional< SourceImpactSnapshot > pending_shared_confirmation_
void RetireBitmap(Bitmap &bitmap)
Explicitly retire resources owned by a bitmap that is being erased.
Definition arena.cc:63
static Arena & Get()
Definition arena.cc:24
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:69
const uint8_t * data() const
Definition bitmap.h:400
TextureHandle texture() const
Definition bitmap.h:403
bool is_active() const
Definition bitmap.h:407
void UpdateTexture()
Updates the underlying SDL_Texture when it already exists.
Definition bitmap.cc:307
Defines an abstract interface for all rendering operations.
Definition irenderer.h:60
SNES 16-bit tile metadata container.
Definition snes_tile.h:52
const Theme & GetCurrentTheme() const
static ThemeManager & Get()
static const std::array< int, 3 > & RuntimeObjectIds()
static CustomObjectManager & Get()
static constexpr int kAtlasTilesPerRow
static bool IsEditableStandardObject(int16_t object_id)
void MarkObjectsDirty()
Definition room.h:431
uint64_t graphics_revision() const
Definition room.h:1024
#define ICON_MD_INFO
Definition icons.h:993
#define ICON_MD_WARNING
Definition icons.h:2123
#define ICON_MD_ERROR
Definition icons.h:686
#define ICON_MD_GRID_ON
Definition icons.h:896
#define ICON_MD_VISIBILITY_OFF
Definition icons.h:2102
#define ICON_MD_ADD
Definition icons.h:86
#define ICON_MD_CHECK_CIRCLE
Definition icons.h:400
#define ICON_MD_SAVE
Definition icons.h:1644
#define ICON_MD_CLOSE
Definition icons.h:418
#define ICON_MD_UNDO
Definition icons.h:2039
bool PathsReferToSameAsset(const std::filesystem::path &lhs, const std::filesystem::path &rhs)
uint16_t TileInfoToWord(TileInfo tile_info)
Definition snes_tile.cc:361
ImVec4 ConvertColorToImVec4(const Color &color)
Definition color.h:134
absl::StatusOr< fs::path > ResolveCustomObjectAssetPath(const std::string &custom_objects_folder, const std::string &filename)
std::string GetObjectName(int object_id)
Represents a group of palettes.
std::vector< uint8_t > custom_source_bytes
std::optional< ObjectTileSourceProvenance > source_provenance
std::filesystem::path custom_resolved_path