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"
11#include "core/features.h"
12#include "imgui/imgui.h"
14
15namespace yaze {
16namespace editor {
17
18namespace {
19
20void MixFingerprint(uint64_t* fingerprint, uint64_t value) {
21 constexpr uint64_t kFnvPrime = 1099511628211ULL;
22 *fingerprint ^= value;
23 *fingerprint *= kFnvPrime;
24}
25
26bool PathsReferToSameAsset(const std::filesystem::path& lhs,
27 const std::filesystem::path& rhs) {
28 if (lhs == rhs) {
29 return true;
30 }
31 std::error_code error;
32 const bool equivalent = std::filesystem::equivalent(lhs, rhs, error);
33 return !error && equivalent;
34}
35
36} // namespace
37
39 : renderer_(renderer), rom_(rom) {
40 tile_editor_ = std::make_unique<zelda3::ObjectTileEditor>(rom);
41}
42
46
53
58
60 std::string message) {
62 action_status_message_ = std::move(message);
63}
64
81
82absl::Status ObjectTileEditorPanel::OpenForObject(int16_t object_id,
83 int room_id,
84 DungeonRoomStore* rooms) {
85 return OpenForObject(object_id, room_id, rooms, current_palette_group_);
86}
87
89 int16_t object_id, int room_id, DungeonRoomStore* rooms,
90 const gfx::PaletteGroup& palette_group) {
92 return absl::UnimplementedError(
93 "Tile editing is not yet supported for this object layout");
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 shared tile edit is not covered by Dungeon Editor Ctrl+Z."));
582 }
583 ImGui::Spacing();
584 const char* apply_label =
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()) {
617 } else {
619 }
620 preview_dirty_ = false;
621 } else {
624 }
625}
626
628 auto* room =
629 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
630 if (room == nullptr) {
633 return;
634 }
635
636 auto status = tile_editor_->BuildTile8Atlas(
637 tile8_atlas_bmp_, room->get_gfx_buffer().data(), current_palette_group_,
640 ? std::optional<int16_t>(current_layout_.object_id)
641 : std::nullopt,
643 if (status.ok()) {
646 } else {
648 }
649 atlas_dirty_ = false;
650 } else {
653 }
654}
655
657 if (selected_cell_index_ < 0 ||
658 selected_cell_index_ >= static_cast<int>(current_layout_.cells.size())) {
660 return;
661 }
662
663 const auto& cell = current_layout_.cells[selected_cell_index_];
664 selected_source_tile_ = static_cast<int>(cell.tile_info.id_);
665
666 const int cell_palette = static_cast<int>(cell.tile_info.palette_);
667 if (source_palette_ != cell_palette) {
668 source_palette_ = cell_palette;
669 atlas_dirty_ = true;
670 }
672}
673
675 uint16_t attributes = 0;
677 selected_cell_index_ < static_cast<int>(current_layout_.cells.size())) {
678 attributes = gfx::TileInfoToWord(
680 0xE000;
681 }
682 if (source_attributes_ != attributes) {
683 source_attributes_ = attributes;
684 atlas_dirty_ = true;
685 }
686}
687
689 if (preview_dirty_) {
691 }
692
693 ImGui::Text(tr("Object Tiles (%dx%d)"), current_layout_.bounds_width,
695
696 constexpr float kScale = 4.0f;
697 float grid_width = current_layout_.bounds_width * 8 * kScale;
698 float grid_height = current_layout_.bounds_height * 8 * kScale;
699
700 ImVec2 canvas_pos = ImGui::GetCursorScreenPos();
701 ImVec2 canvas_size(grid_width, grid_height);
702
703 // Draw the preview bitmap as background
705 ImGui::Image((ImTextureID)(intptr_t)object_preview_bmp_.texture(),
706 canvas_size);
707 } else {
708 ImGui::Dummy(canvas_size);
709 }
710
711 ImDrawList* draw_list = ImGui::GetWindowDrawList();
712
713 // Draw 8px grid overlay
714 for (int gx = 0; gx <= current_layout_.bounds_width; ++gx) {
715 float line_x = canvas_pos.x + gx * 8 * kScale;
716 draw_list->AddLine(ImVec2(line_x, canvas_pos.y),
717 ImVec2(line_x, canvas_pos.y + grid_height),
718 IM_COL32(128, 128, 128, 80));
719 }
720 for (int gy = 0; gy <= current_layout_.bounds_height; ++gy) {
721 float line_y = canvas_pos.y + gy * 8 * kScale;
722 draw_list->AddLine(ImVec2(canvas_pos.x, line_y),
723 ImVec2(canvas_pos.x + grid_width, line_y),
724 IM_COL32(128, 128, 128, 80));
725 }
726
727 // Highlight selected cell
728 if (selected_cell_index_ >= 0 &&
729 selected_cell_index_ < static_cast<int>(current_layout_.cells.size())) {
730 const auto& cell = current_layout_.cells[selected_cell_index_];
731 ImVec2 cell_min(canvas_pos.x + cell.rel_x * 8 * kScale,
732 canvas_pos.y + cell.rel_y * 8 * kScale);
733 ImVec2 cell_max(cell_min.x + 8 * kScale, cell_min.y + 8 * kScale);
734 draw_list->AddRect(cell_min, cell_max, IM_COL32(255, 255, 0, 255), 0, 0,
735 2.0f);
736 }
737
738 // Handle clicks on the grid
739 if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
740 ImVec2 mouse = ImGui::GetMousePos();
741 int click_tile_x =
742 static_cast<int>((mouse.x - canvas_pos.x) / (8 * kScale));
743 int click_tile_y =
744 static_cast<int>((mouse.y - canvas_pos.y) / (8 * kScale));
745
746 // Find the cell at this position
747 for (int idx = 0; idx < static_cast<int>(current_layout_.cells.size());
748 ++idx) {
749 if (current_layout_.cells[idx].rel_x == click_tile_x &&
750 current_layout_.cells[idx].rel_y == click_tile_y) {
753 break;
754 }
755 }
756 }
757
758 // Show cell count
759 int modified_count = 0;
760 for (const auto& cell : current_layout_.cells) {
761 if (cell.modified)
762 ++modified_count;
763 }
764 ImGui::Text(tr("%zu tiles, %d modified"), current_layout_.cells.size(),
765 modified_count);
766}
767
769 if (atlas_dirty_) {
771 }
772
773 ImGui::Text(tr("Source Tiles (Palette %d)"), source_palette_);
774
775 // Palette selector
776 ImGui::SameLine();
777 ImGui::SetNextItemWidth(80);
778 if (ImGui::SliderInt("##SrcPal", &source_palette_, 0, 7)) {
779 atlas_dirty_ = true;
780 }
781
782 constexpr float kAtlasScale = 2.0f;
783 float display_width = zelda3::ObjectTileEditor::kAtlasWidthPx * kAtlasScale;
784 float display_height = zelda3::ObjectTileEditor::kAtlasHeightPx * kAtlasScale;
785
786 ImVec2 atlas_pos = ImGui::GetCursorScreenPos();
787 ImVec2 atlas_size(display_width, display_height);
788
789 // Scrollable child for the atlas
790 ImGui::BeginChild("##AtlasScroll", ImVec2(display_width + 16, 300), true,
791 ImGuiWindowFlags_HorizontalScrollbar);
792
793 atlas_pos = ImGui::GetCursorScreenPos();
794
796 ImGui::Image((ImTextureID)(intptr_t)tile8_atlas_bmp_.texture(), atlas_size);
797 } else {
798 ImGui::Dummy(atlas_size);
799 }
800
801 ImDrawList* draw_list = ImGui::GetWindowDrawList();
802
803 // Draw 8px grid
804 for (int gx = 0; gx <= zelda3::ObjectTileEditor::kAtlasTilesPerRow; ++gx) {
805 float line_x = atlas_pos.x + gx * 8 * kAtlasScale;
806 draw_list->AddLine(ImVec2(line_x, atlas_pos.y),
807 ImVec2(line_x, atlas_pos.y + display_height),
808 IM_COL32(64, 64, 64, 60));
809 }
810 for (int gy = 0; gy <= zelda3::ObjectTileEditor::kAtlasTileRows; ++gy) {
811 float line_y = atlas_pos.y + gy * 8 * kAtlasScale;
812 draw_list->AddLine(ImVec2(atlas_pos.x, line_y),
813 ImVec2(atlas_pos.x + display_width, line_y),
814 IM_COL32(64, 64, 64, 60));
815 }
816
817 // Highlight selected source tile
818 if (selected_source_tile_ >= 0) {
819 int src_col =
821 int src_row =
823 ImVec2 sel_min(atlas_pos.x + src_col * 8 * kAtlasScale,
824 atlas_pos.y + src_row * 8 * kAtlasScale);
825 ImVec2 sel_max(sel_min.x + 8 * kAtlasScale, sel_min.y + 8 * kAtlasScale);
826 draw_list->AddRect(sel_min, sel_max, IM_COL32(0, 255, 255, 255), 0, 0,
827 2.0f);
828 }
829
830 // Handle clicks on the atlas
831 if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
832 ImVec2 mouse = ImGui::GetMousePos();
833 int click_col =
834 static_cast<int>((mouse.x - atlas_pos.x) / (8 * kAtlasScale));
835 int click_row =
836 static_cast<int>((mouse.y - atlas_pos.y) / (8 * kAtlasScale));
837
838 if (click_col >= 0 &&
840 click_row >= 0 &&
842 int tile_id =
843 click_row * zelda3::ObjectTileEditor::kAtlasTilesPerRow + click_col;
844 selected_source_tile_ = tile_id;
845
846 // If a cell is selected, replace its tile
847 if (selected_cell_index_ >= 0 &&
849 static_cast<int>(current_layout_.cells.size())) {
851 cell.tile_info.id_ = static_cast<uint16_t>(tile_id);
852 cell.tile_info.palette_ = static_cast<uint8_t>(source_palette_);
853 cell.modified = true;
854 preview_dirty_ = true;
856 }
857 }
858 }
859
860 ImGui::EndChild();
861
862 if (selected_source_tile_ >= 0) {
863 ImGui::Text(tr("Tile: 0x%03X"), selected_source_tile_);
864 }
865}
866
868 if (selected_cell_index_ < 0 ||
869 selected_cell_index_ >= static_cast<int>(current_layout_.cells.size())) {
870 ImGui::TextDisabled(tr("Select a tile cell to edit properties"));
871 return;
872 }
873
875 ImGui::Text(tr("Cell (%d, %d)"), cell.rel_x, cell.rel_y);
876 ImGui::SameLine();
877
878 // Tile ID
879 int tile_id = cell.tile_info.id_;
880 ImGui::SetNextItemWidth(80);
881 if (ImGui::InputInt(tr("ID"), &tile_id, 1, 16)) {
882 cell.tile_info.id_ = static_cast<uint16_t>(tile_id & 0x3FF);
883 cell.modified = true;
884 preview_dirty_ = true;
887 }
888 ImGui::SameLine();
889
890 // Palette
891 int pal = cell.tile_info.palette_;
892 ImGui::SetNextItemWidth(60);
893 if (ImGui::SliderInt(tr("Pal"), &pal, 0, 7)) {
894 cell.tile_info.palette_ = static_cast<uint8_t>(pal);
895 cell.modified = true;
896 preview_dirty_ = true;
899 }
900 ImGui::SameLine();
901
902 // Flip flags
903 if (ImGui::Checkbox(tr("H"), &cell.tile_info.horizontal_mirror_)) {
904 cell.modified = true;
905 preview_dirty_ = true;
908 }
909 ImGui::SameLine();
910 if (ImGui::Checkbox(tr("V"), &cell.tile_info.vertical_mirror_)) {
911 cell.modified = true;
912 preview_dirty_ = true;
915 }
916 ImGui::SameLine();
917 if (ImGui::Checkbox(tr("Pri"), &cell.tile_info.over_)) {
918 cell.modified = true;
919 preview_dirty_ = true;
922 }
923}
924
927 return tile_editor_->WriteBack(current_layout_);
928 }
929
930 auto plan_or = tile_editor_->BuildStandardWritePlan(current_layout_);
931 if (!plan_or.ok()) {
932 return plan_or.status();
933 }
934
935 const auto& plan = *plan_or;
936 if (!plan.writes_pattern_source() && standard_write_preflight_ &&
937 !plan.write_ranges().empty()) {
938 const absl::Status preflight =
939 standard_write_preflight_(plan.write_ranges());
940 if (!preflight.ok()) {
941 return preflight;
942 }
943 }
944
945 return tile_editor_->ApplyStandardWritePlan(plan);
946}
947
948void ObjectTileEditorPanel::ApplyChanges(bool confirm_shared) {
949 // Resolve the complete source impact before every write, including
950 // the second call after the user accepts the shared-data confirmation. A
951 // malformed or unresolved object family must block the write rather than be
952 // interpreted as an unshared source.
953 const auto impact_snapshot_or = AnalyzeSourceImpactSnapshot();
954 if (!impact_snapshot_or.ok()) {
955 show_shared_confirm_ = false;
960 absl::StrFormat(ICON_MD_ERROR
961 " Apply blocked: source impact could not be resolved: "
962 "%s",
963 impact_snapshot_or.status().message()));
964 return;
965 }
966 const SourceImpactSnapshot impact_snapshot = *impact_snapshot_or;
967 const int shared_count = impact_snapshot.consumer_count;
968 if (confirm_shared && shared_count > 1) {
969 shared_object_count_ = shared_count;
970 pending_shared_confirmation_ = impact_snapshot;
975 absl::StrFormat(
977 " Confirm shared asset publish: %d runtime slots use this "
978 "file. This project-source edit is not covered by Ctrl+Z.",
979 shared_count));
980 } else {
983 absl::StrFormat(ICON_MD_WARNING
984 " Confirm global apply: %d consumers use this tile "
985 "data. This edit is not covered by Ctrl+Z.",
986 shared_count));
987 }
988 return;
989 }
990 const bool missing_required_confirmation =
991 shared_count > 1 && !pending_shared_confirmation_.has_value();
992 const bool confirmed_impact_changed =
993 pending_shared_confirmation_.has_value() &&
994 *pending_shared_confirmation_ != impact_snapshot;
995 if (!confirm_shared &&
996 (missing_required_confirmation || confirmed_impact_changed)) {
997 shared_object_count_ = shared_count;
998 pending_shared_confirmation_ = impact_snapshot;
1003 absl::StrFormat(
1005 " Source impact changed; review and confirm the updated "
1006 "publish for %d runtime slots. This project-source edit is not "
1007 "covered by Ctrl+Z.",
1008 shared_count));
1009 } else {
1012 absl::StrFormat(
1014 " Source impact changed; review and confirm the updated global "
1015 "apply for %d consumers. This edit is not covered by Ctrl+Z.",
1016 shared_count));
1017 }
1018 return;
1019 }
1020
1021 show_shared_confirm_ = false;
1024
1025 auto status = WriteBackCurrentLayout();
1026 if (status.ok()) {
1028 const auto refreshed =
1030 if (refreshed.ok() && *refreshed) {
1031 current_layout_.pattern_source = *refreshed;
1032 }
1033 }
1034 if (rooms_ != nullptr) {
1036 [](int, zelda3::Room& room) { room.MarkObjectsDirty(); });
1037 }
1038 if (on_tiles_applied_) {
1040 }
1041
1042 // Re-render the current room immediately. Other materialized rooms were
1043 // marked dirty above and refresh lazily when their canvases draw.
1045 auto& room = (*rooms_)[current_room_id_];
1046 room.MarkObjectsDirty();
1047 room.RenderRoomGraphics();
1048 }
1049
1050 // Update both the cells and the authoritative source snapshot only after
1051 // the write succeeds. The next impact analysis reuses BuildStandardWritePlan
1052 // as its CAS gate, so leaving expected_words stale here would make every
1053 // later apply fail until the panel was reopened.
1054 for (auto& cell : current_layout_.cells) {
1055 if (cell.modified) {
1056 const uint16_t applied_word = gfx::TileInfoToWord(cell.tile_info);
1057 cell.original_word = applied_word;
1059 current_layout_.source_provenance.has_value() &&
1060 cell.source_ref.has_value()) {
1061 const auto& source_ref = *cell.source_ref;
1062 auto& spans = current_layout_.source_provenance->spans;
1063 if (source_ref.span_index < spans.size() &&
1064 source_ref.word_index <
1065 spans[source_ref.span_index].expected_words.size()) {
1066 spans[source_ref.span_index].expected_words[source_ref.word_index] =
1067 applied_word;
1068 }
1069 }
1070 cell.modified = false;
1071 }
1072 }
1073
1075 if (shared_count > 1) {
1079 absl::StrFormat(
1081 " Published shared custom asset used by %d runtime slots.",
1082 shared_count));
1083 } else {
1086 absl::StrFormat(
1088 " Applied changes to shared tile data used by %d consumers.",
1089 shared_count));
1090 }
1091 } else {
1093 }
1094 return;
1095 }
1096
1099 absl::StrFormat(ICON_MD_ERROR " Apply failed: %s", status.message()));
1100}
1101
1103 const auto& theme = gui::ThemeManager::Get().GetCurrentTheme();
1104 int modified_count = 0;
1105 for (const auto& cell : current_layout_.cells) {
1106 if (cell.modified)
1107 ++modified_count;
1108 }
1109
1110 bool has_mods = modified_count > 0;
1111
1112 if (has_mods) {
1113 ImGui::Text(tr("%d tile(s) modified"), modified_count);
1114 ImGui::SameLine();
1115 }
1116
1117 // Shared tile data warning. Keep analysis errors visible here, but enforce
1118 // fail-closed behavior in ApplyChanges so the disabled-by-default standard
1119 // editor cannot silently turn an unknown impact into a write.
1120 const auto shared_count_or = GetDisplayedSharedTileDataUsageCount();
1121 if (!shared_count_or.ok()) {
1122 ImGui::TextColored(gui::ConvertColorToImVec4(theme.error),
1123 ICON_MD_ERROR " Source impact unavailable");
1124 if (ImGui::IsItemHovered()) {
1125 ImGui::SetItemTooltip("%s", shared_count_or.status().message().data());
1126 }
1127 ImGui::SameLine();
1128 } else if (*shared_count_or > 1) {
1129 const int shared_count = *shared_count_or;
1131 ImGui::TextColored(gui::ConvertColorToImVec4(theme.warning),
1132 ICON_MD_WARNING " Shared by %d runtime slots",
1133 shared_count);
1134 } else {
1135 ImGui::TextColored(gui::ConvertColorToImVec4(theme.warning),
1136 ICON_MD_WARNING " Shared by %d consumers",
1137 shared_count);
1138 }
1139 if (ImGui::IsItemHovered()) {
1141 ImGui::SetItemTooltip(
1142 tr("%d fixed runtime slots resolve to this source file.\n"
1143 "Publishing changes updates all of them.\nThis project "
1144 "source-asset edit is not covered by Ctrl+Z."),
1145 shared_count);
1146 } else {
1147 ImGui::SetItemTooltip(
1148 tr("This object reuses tile data with %d consumers.\nApplying "
1149 "changes will update every object or runtime consumer in that "
1150 "shared data group.\nThis shared tile edit is not covered by "
1151 "Ctrl+Z."),
1152 shared_count);
1153 }
1154 }
1155 ImGui::SameLine();
1156 }
1157
1158 // Apply button
1159 if (!has_mods)
1160 ImGui::BeginDisabled();
1161 const char* primary_action_label =
1163 ? ICON_MD_SAVE " Publish Asset"
1164 : ICON_MD_SAVE " Apply";
1165 if (ImGui::Button(primary_action_label)) {
1166 ApplyChanges();
1167 }
1168 if (!has_mods)
1169 ImGui::EndDisabled();
1170
1171 ImGui::SameLine();
1172
1173 // Revert button
1174 if (!has_mods)
1175 ImGui::BeginDisabled();
1176 if (ImGui::Button(ICON_MD_UNDO " Revert")) {
1178 }
1179 if (!has_mods)
1180 ImGui::EndDisabled();
1181
1182 ImGui::SameLine();
1183
1184 const char* close_label =
1185 has_mods ? ICON_MD_CLOSE " Discard & Close" : ICON_MD_CLOSE " Close";
1186 if (ImGui::Button(close_label)) {
1187 if (p_open != nullptr) {
1188 *p_open = false;
1189 }
1190 Close();
1191 return;
1192 }
1193 if (has_mods && ImGui::IsItemHovered()) {
1194 ImGui::SetItemTooltip(
1195 "%s", tr("Close the panel and discard all unapplied tile changes."));
1196 }
1197
1198 ImGui::Spacing();
1200 const auto& path = current_layout_.pattern_source->source_path;
1201 ImGui::Text("Source: %s",
1202 std::filesystem::path(path).filename().string().c_str());
1203 if (ImGui::IsItemHovered())
1204 ImGui::SetTooltip("%s", path.c_str());
1205 }
1206 ImGui::TextDisabled(
1208 ? ICON_MD_INFO
1209 " Project source-asset edit; not covered by Ctrl+Z."
1210 : ICON_MD_INFO " Global ROM tile edit; not covered by Ctrl+Z.");
1211
1213 !action_status_message_.empty()) {
1214 ImVec4 status_color = gui::ConvertColorToImVec4(theme.text_secondary);
1215 switch (action_status_tone_) {
1217 status_color = gui::ConvertColorToImVec4(theme.warning);
1218 break;
1220 status_color = gui::ConvertColorToImVec4(theme.success);
1221 break;
1223 status_color = gui::ConvertColorToImVec4(theme.error);
1224 break;
1226 break;
1227 }
1228
1229 ImGui::Spacing();
1230 gui::StyleColorGuard status_text(ImGuiCol_Text, status_color);
1231 ImGui::PushTextWrapPos(0.0f);
1232 ImGui::TextUnformatted(action_status_message_.c_str());
1233 ImGui::PopTextWrapPos();
1234 }
1235}
1236
1239 if (p_open != nullptr) {
1240 *p_open = false;
1241 }
1244 " Unapplied tile changes were kept. Reopen the panel to continue, or "
1245 "use Discard & Close to throw them away.");
1246 return;
1247 }
1248
1249 if (p_open != nullptr) {
1250 *p_open = false;
1251 }
1252 Close();
1253}
1254
1256 // Only handle shortcuts when this window is focused
1257 if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows))
1258 return;
1259 if (current_layout_.cells.empty())
1260 return;
1261
1262 int cell_count = static_cast<int>(current_layout_.cells.size());
1263
1264 // Arrow keys: navigate selected cell by spatial position
1265 auto find_neighbor = [&](int dx, int dy) -> int {
1266 if (selected_cell_index_ < 0)
1267 return 0;
1268 const auto& cur = current_layout_.cells[selected_cell_index_];
1269 int target_x = cur.rel_x + dx;
1270 int target_y = cur.rel_y + dy;
1271 for (int i = 0; i < cell_count; ++i) {
1272 if (current_layout_.cells[i].rel_x == target_x &&
1273 current_layout_.cells[i].rel_y == target_y) {
1274 return i;
1275 }
1276 }
1277 return selected_cell_index_;
1278 };
1279
1280 if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) {
1281 selected_cell_index_ = find_neighbor(-1, 0);
1283 }
1284 if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) {
1285 selected_cell_index_ = find_neighbor(1, 0);
1287 }
1288 if (ImGui::IsKeyPressed(ImGuiKey_UpArrow, false)) {
1289 selected_cell_index_ = find_neighbor(0, -1);
1291 }
1292 if (ImGui::IsKeyPressed(ImGuiKey_DownArrow, false)) {
1293 selected_cell_index_ = find_neighbor(0, 1);
1295 }
1296
1297 // Number keys 0-7: set palette on selected cell
1298 if (selected_cell_index_ >= 0 && selected_cell_index_ < cell_count) {
1300 for (int key = 0; key <= 7; ++key) {
1301 if (ImGui::IsKeyPressed(static_cast<ImGuiKey>(ImGuiKey_0 + key), false)) {
1302 cell.tile_info.palette_ = static_cast<uint8_t>(key);
1303 cell.modified = true;
1304 preview_dirty_ = true;
1307 }
1308 }
1309
1310 // H: toggle horizontal flip
1311 if (ImGui::IsKeyPressed(ImGuiKey_H, false)) {
1312 cell.tile_info.horizontal_mirror_ = !cell.tile_info.horizontal_mirror_;
1313 cell.modified = true;
1314 preview_dirty_ = true;
1317 }
1318
1319 // V: toggle vertical flip
1320 if (ImGui::IsKeyPressed(ImGuiKey_V, false)) {
1321 cell.tile_info.vertical_mirror_ = !cell.tile_info.vertical_mirror_;
1322 cell.modified = true;
1323 preview_dirty_ = true;
1326 }
1327
1328 // P: toggle priority
1329 if (ImGui::IsKeyPressed(ImGuiKey_P, false)) {
1330 cell.tile_info.over_ = !cell.tile_info.over_;
1331 cell.modified = true;
1332 preview_dirty_ = true;
1335 }
1336 }
1337
1338 // Escape: deselect or close
1339 if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
1340 if (selected_cell_index_ >= 0) {
1343 } else {
1344 RequestSafeWindowClose(p_open);
1345 }
1346 return;
1347 }
1348
1349 // Tab: cycle to next cell
1350 if (ImGui::IsKeyPressed(ImGuiKey_Tab, false)) {
1351 if (cell_count > 0) {
1352 selected_cell_index_ = (selected_cell_index_ + 1) % cell_count;
1354 }
1355 }
1356}
1357
1358} // namespace editor
1359} // 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:49
uint64_t object_tile_revision() const
Definition rom.h:196
bool is_loaded() const
Definition rom.h:187
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:70
const uint8_t * data() const
Definition bitmap.h:407
TextureHandle texture() const
Definition bitmap.h:410
void CreateTexture()
Creates the underlying SDL_Texture to be displayed.
Definition bitmap.cc:297
bool is_active() const
Definition bitmap.h:414
void UpdateTexture()
Updates the underlying SDL_Texture when it already exists.
Definition bitmap.cc:301
Defines an abstract interface for all rendering operations.
Definition irenderer.h:60
SNES 16-bit tile metadata container.
Definition snes_tile.h:52
RAII guard for ImGui style colors.
Definition style_guard.h:27
const Theme & GetCurrentTheme() const
static ThemeManager & Get()
static const std::array< int, 3 > & RuntimeObjectIds()
static CustomObjectManager & Get()
absl::StatusOr< std::shared_ptr< const DungeonTilePatterns > > GetTilePatterns()
static constexpr int kAtlasTilesPerRow
static bool IsEditableStandardObject(int16_t object_id)
void MarkObjectsDirty()
Definition room.h:471
uint64_t graphics_revision() const
Definition room.h:1125
#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:362
ImVec4 ConvertColorToImVec4(const Color &color)
Definition color.h:140
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::shared_ptr< const DungeonTilePatterns > pattern_source
std::vector< uint8_t > custom_source_bytes
std::optional< ObjectTileSourceProvenance > source_provenance
std::filesystem::path custom_resolved_path