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 "absl/strings/str_format.h"
7#include "imgui/imgui.h"
9
10namespace yaze {
11namespace editor {
12
13namespace {
14
15void MixFingerprint(uint64_t* fingerprint, uint64_t value) {
16 constexpr uint64_t kFnvPrime = 1099511628211ULL;
17 *fingerprint ^= value;
18 *fingerprint *= kFnvPrime;
19}
20
21} // namespace
22
24 : renderer_(renderer), rom_(rom) {
25 tile_editor_ = std::make_unique<zelda3::ObjectTileEditor>(rom);
26}
27
32
37
39 std::string message) {
41 action_status_message_ = std::move(message);
42}
43
58
59absl::Status ObjectTileEditorPanel::OpenForObject(int16_t object_id,
60 int room_id,
61 DungeonRoomStore* rooms) {
62 return OpenForObject(object_id, room_id, rooms, current_palette_group_);
63}
64
66 int16_t object_id, int room_id, DungeonRoomStore* rooms,
67 const gfx::PaletteGroup& palette_group) {
69 return absl::UnimplementedError(
70 "Standard object tile editing is supported only for audited objects "
71 "0x11F and 0x120");
72 }
73 if (rooms == nullptr) {
74 return absl::InvalidArgumentError(
75 "A dungeon room store is required to edit object tiles");
76 }
77 if (room_id < 0 || room_id >= static_cast<int>(rooms->size())) {
78 return absl::OutOfRangeError("Dungeon room id is outside the room store");
79 }
81 return absl::FailedPreconditionError(
82 "Apply, revert, or close the current Object Tile Editor session "
83 "before opening another object");
84 }
85
86 auto& room = (*rooms)[room_id];
87 auto layout_or =
88 tile_editor_->CaptureEditableObjectLayout(object_id, room, palette_group);
89 if (!layout_or.ok()) {
90 return layout_or.status();
91 }
92
93 // Do not disturb a live editor session until every fail-closed capture check
94 // has passed. In particular, a bad selection must not discard unsaved edits.
95 current_object_id_ = object_id;
96 current_room_id_ = room_id;
97 rooms_ = rooms;
98 current_palette_group_ = palette_group;
99 is_new_object_ = false;
100 current_layout_ = std::move(*layout_or);
102 is_open_ = true;
104 return absl::OkStatus();
105}
106
108 int width, int height, const std::string& filename, int16_t object_id,
109 int room_id, DungeonRoomStore* rooms) {
111 return absl::FailedPreconditionError(
112 "Apply, revert, or explicitly discard the current Object Tile Editor "
113 "session before creating another object");
114 }
115 current_object_id_ = object_id;
116 current_room_id_ = room_id;
117 rooms_ = rooms;
119 is_open_ = true;
120 is_new_object_ = true;
121
123 zelda3::ObjectTileLayout::CreateEmpty(width, height, object_id, filename);
125 return absl::OkStatus();
126}
127
129 is_open_ = false;
130 is_new_object_ = false;
131 current_layout_ = {};
132 current_room_id_ = -1;
134 rooms_ = nullptr;
136}
137
139 // Hiding a modified editor window must not silently throw away tile edits.
140 // The session remains available when the workspace window is reopened; the
141 // explicit action-bar close remains the user's discard action.
143 Close();
144 }
145}
146
153
155 int room_id, const gfx::PaletteGroup& group) {
156 if (is_open_ && current_room_id_ != room_id) {
157 return;
158 }
160}
161
163 if (is_new_object_) {
164 return absl::StrFormat(ICON_MD_ADD_BOX " New Object (%dx%d) - %s",
168 }
169
171 return absl::StrFormat(ICON_MD_GRID_ON " Custom Object 0x%03X - %s",
174 }
175
176 return absl::StrFormat(ICON_MD_GRID_ON " Object 0x%03X - %s",
179}
180
191
192absl::StatusOr<ObjectTileEditorPanel::SourceImpactSnapshot>
195 uint64_t fingerprint = 1469598103934665603ULL;
196 MixFingerprint(&fingerprint,
197 static_cast<uint64_t>(shared_tile_data_usage_override_));
199 }
200
202 return SourceImpactSnapshot{};
203 }
204 if (current_object_id_ < 0 ||
206 return absl::FailedPreconditionError(
207 "No matching standard object is selected for source-impact analysis");
208 }
209 if (rom_ == nullptr || !rom_->is_loaded()) {
210 return absl::FailedPreconditionError(
211 "A loaded ROM is required for source-impact analysis");
212 }
213
214 auto impact_or =
215 tile_editor_->AnalyzeStandardTileSourceImpact(current_layout_);
216 if (!impact_or.ok()) {
217 return impact_or.status();
218 }
219
220 uint64_t fingerprint = 1469598103934665603ULL;
221 MixFingerprint(&fingerprint, impact_or->affected_objects.size());
222 for (const auto& entry : impact_or->affected_objects) {
223 MixFingerprint(&fingerprint, 0xA11EC7EDULL);
224 MixFingerprint(&fingerprint, static_cast<uint16_t>(entry.object_id));
225 MixFingerprint(&fingerprint, entry.overlapping_ranges.size());
226 for (const auto& range : entry.overlapping_ranges) {
227 MixFingerprint(&fingerprint, range.begin);
228 MixFingerprint(&fingerprint, range.end);
229 }
230 }
231 MixFingerprint(&fingerprint, impact_or->runtime_consumers.size());
232 for (const auto& entry : impact_or->runtime_consumers) {
233 MixFingerprint(&fingerprint, 0x52754E71ULL);
234 MixFingerprint(&fingerprint, static_cast<uint64_t>(entry.consumer));
235 MixFingerprint(&fingerprint, entry.overlapping_ranges.size());
236 for (const auto& range : entry.overlapping_ranges) {
237 MixFingerprint(&fingerprint, range.begin);
238 MixFingerprint(&fingerprint, range.end);
239 }
240 }
241 return SourceImpactSnapshot{static_cast<int>(impact_or->consumer_count()),
242 fingerprint};
243}
244
246 auto snapshot_or = AnalyzeSourceImpactSnapshot();
247 if (!snapshot_or.ok()) {
248 return snapshot_or.status();
249 }
250 return snapshot_or->consumer_count;
251}
252
254 uint64_t fingerprint = 1469598103934665603ULL;
255 MixFingerprint(&fingerprint,
256 static_cast<uint16_t>(current_layout_.object_id));
257 MixFingerprint(&fingerprint,
258 static_cast<uint32_t>(current_layout_.tile_data_address));
259 MixFingerprint(&fingerprint, current_layout_.is_custom ? 1 : 0);
260 MixFingerprint(&fingerprint,
261 static_cast<uint32_t>(current_layout_.bounds_width));
262 MixFingerprint(&fingerprint,
263 static_cast<uint32_t>(current_layout_.bounds_height));
264 MixFingerprint(&fingerprint, current_layout_.cells.size());
265 for (const auto& cell : current_layout_.cells) {
266 MixFingerprint(&fingerprint, static_cast<uint32_t>(cell.rel_x));
267 MixFingerprint(&fingerprint, static_cast<uint32_t>(cell.rel_y));
268 MixFingerprint(&fingerprint, cell.original_word);
269 MixFingerprint(&fingerprint, cell.source_ref.has_value() ? 1 : 0);
270 if (cell.source_ref.has_value()) {
271 MixFingerprint(&fingerprint, cell.source_ref->span_index);
272 MixFingerprint(&fingerprint, cell.source_ref->word_index);
273 }
274 }
275 MixFingerprint(&fingerprint,
276 current_layout_.source_provenance.has_value() ? 1 : 0);
277 if (current_layout_.source_provenance.has_value()) {
278 const auto& provenance = *current_layout_.source_provenance;
279 MixFingerprint(&fingerprint, static_cast<uint16_t>(provenance.object_id));
280 MixFingerprint(&fingerprint, provenance.descriptor_pc_address);
281 MixFingerprint(&fingerprint, provenance.expected_descriptor_word);
282 MixFingerprint(&fingerprint, provenance.spans.size());
283 for (const auto& span : provenance.spans) {
284 MixFingerprint(&fingerprint, span.pc_address);
285 MixFingerprint(&fingerprint, span.expected_words.size());
286 for (const uint16_t word : span.expected_words) {
287 MixFingerprint(&fingerprint, word);
288 }
289 }
290 }
291 return fingerprint;
292}
293
294absl::StatusOr<int>
308
310 auto usage_count_or = GetSharedTileDataUsageCount();
311 if (!usage_count_or.ok()) {
312 return usage_count_or.status();
313 }
314 return *usage_count_or > 1;
315}
316
318 return rooms_ != nullptr && current_room_id_ >= 0 &&
319 current_room_id_ < static_cast<int>(rooms_->size()) &&
320 !current_layout_.cells.empty();
321}
322
324 preview_dirty_ = true;
325 atlas_dirty_ = true;
326
328 return;
329 }
330
333}
334
335void ObjectTileEditorPanel::Draw(bool* p_open) {
336 if (!is_open_ || current_layout_.cells.empty()) {
337 if (p_open != nullptr) {
338 *p_open = false;
339 }
340 return;
341 }
342 if (p_open != nullptr && !*p_open) {
343 return;
344 }
345
346 const std::string session_title = BuildWindowTitle();
347 ImGui::TextUnformatted(session_title.c_str());
348 ImGui::TextDisabled(tr("Room 0x%03X"), current_room_id_);
349 ImGui::Separator();
350
351 // Two-column layout: tile grid + source sheet
352 if (ImGui::BeginTable(
353 "##TileEditorLayout", 2,
354 ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV)) {
355 ImGui::TableSetupColumn("Tile Grid", ImGuiTableColumnFlags_WidthFixed,
356 280.0f);
357 ImGui::TableSetupColumn("Source Sheet", ImGuiTableColumnFlags_WidthStretch);
358
359 ImGui::TableNextRow();
360 ImGui::TableNextColumn();
361 DrawTileGrid();
362
363 ImGui::TableNextColumn();
365
366 ImGui::EndTable();
367 }
368
369 ImGui::Separator();
371 ImGui::Separator();
372 DrawActionBar(p_open);
373
375
376 // Shared tile data confirmation modal
378 ImGui::OpenPopup("Shared Tile Data (Global Edit)");
379 show_shared_confirm_ = false;
380 }
381 if (ImGui::BeginPopupModal("Shared Tile Data (Global Edit)", nullptr,
382 ImGuiWindowFlags_AlwaysAutoResize)) {
383 ImGui::Text(tr("This tile data is shared by %d consumers."),
385 ImGui::Text(tr("Changes will affect all of them."));
386 ImGui::TextWrapped(tr(
387 "This global ROM tile edit is not covered by Dungeon Editor Ctrl+Z."));
388 ImGui::Spacing();
389 if (ImGui::Button(tr("Apply Global Edit"), ImVec2(150, 0))) {
390 ApplyChanges(/*confirm_shared=*/false);
391 ImGui::CloseCurrentPopup();
392 }
393 ImGui::SameLine();
394 if (ImGui::Button(tr("Cancel"), ImVec2(120, 0))) {
397 ImGui::CloseCurrentPopup();
398 }
399 ImGui::EndPopup();
400 }
401}
402
404 if (!rooms_ || current_room_id_ < 0 ||
405 current_room_id_ >= static_cast<int>(rooms_->size())) {
407 return;
408 }
409 auto& room = (*rooms_)[current_room_id_];
410
411 auto status = tile_editor_->RenderLayoutToBitmap(
412 current_layout_, object_preview_bmp_, room.get_gfx_buffer().data(),
414 if (status.ok()) {
416 preview_dirty_ = false;
417 } else {
419 }
420}
421
423 if (!rooms_ || current_room_id_ < 0 ||
424 current_room_id_ >= static_cast<int>(rooms_->size())) {
426 return;
427 }
428 auto& room = (*rooms_)[current_room_id_];
429
430 auto status = tile_editor_->BuildTile8Atlas(
431 tile8_atlas_bmp_, room.get_gfx_buffer().data(), current_palette_group_,
433 if (status.ok()) {
435 atlas_dirty_ = false;
436 } else {
438 }
439}
440
442 if (selected_cell_index_ < 0 ||
443 selected_cell_index_ >= static_cast<int>(current_layout_.cells.size())) {
444 return;
445 }
446
447 const auto& cell = current_layout_.cells[selected_cell_index_];
448 selected_source_tile_ = static_cast<int>(cell.tile_info.id_);
449
450 const int cell_palette = static_cast<int>(cell.tile_info.palette_);
451 if (source_palette_ != cell_palette) {
452 source_palette_ = cell_palette;
453 atlas_dirty_ = true;
454 }
455}
456
458 if (preview_dirty_) {
460 }
461
462 ImGui::Text(tr("Object Tiles (%dx%d)"), current_layout_.bounds_width,
464
465 constexpr float kScale = 4.0f;
466 float grid_width = current_layout_.bounds_width * 8 * kScale;
467 float grid_height = current_layout_.bounds_height * 8 * kScale;
468
469 ImVec2 canvas_pos = ImGui::GetCursorScreenPos();
470 ImVec2 canvas_size(grid_width, grid_height);
471
472 // Draw the preview bitmap as background
474 ImGui::Image((ImTextureID)(intptr_t)object_preview_bmp_.texture(),
475 canvas_size);
476 } else {
477 ImGui::Dummy(canvas_size);
478 }
479
480 ImDrawList* draw_list = ImGui::GetWindowDrawList();
481
482 // Draw 8px grid overlay
483 for (int gx = 0; gx <= current_layout_.bounds_width; ++gx) {
484 float line_x = canvas_pos.x + gx * 8 * kScale;
485 draw_list->AddLine(ImVec2(line_x, canvas_pos.y),
486 ImVec2(line_x, canvas_pos.y + grid_height),
487 IM_COL32(128, 128, 128, 80));
488 }
489 for (int gy = 0; gy <= current_layout_.bounds_height; ++gy) {
490 float line_y = canvas_pos.y + gy * 8 * kScale;
491 draw_list->AddLine(ImVec2(canvas_pos.x, line_y),
492 ImVec2(canvas_pos.x + grid_width, line_y),
493 IM_COL32(128, 128, 128, 80));
494 }
495
496 // Highlight selected cell
497 if (selected_cell_index_ >= 0 &&
498 selected_cell_index_ < static_cast<int>(current_layout_.cells.size())) {
499 const auto& cell = current_layout_.cells[selected_cell_index_];
500 ImVec2 cell_min(canvas_pos.x + cell.rel_x * 8 * kScale,
501 canvas_pos.y + cell.rel_y * 8 * kScale);
502 ImVec2 cell_max(cell_min.x + 8 * kScale, cell_min.y + 8 * kScale);
503 draw_list->AddRect(cell_min, cell_max, IM_COL32(255, 255, 0, 255), 0, 0,
504 2.0f);
505 }
506
507 // Handle clicks on the grid
508 if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
509 ImVec2 mouse = ImGui::GetMousePos();
510 int click_tile_x =
511 static_cast<int>((mouse.x - canvas_pos.x) / (8 * kScale));
512 int click_tile_y =
513 static_cast<int>((mouse.y - canvas_pos.y) / (8 * kScale));
514
515 // Find the cell at this position
516 for (int idx = 0; idx < static_cast<int>(current_layout_.cells.size());
517 ++idx) {
518 if (current_layout_.cells[idx].rel_x == click_tile_x &&
519 current_layout_.cells[idx].rel_y == click_tile_y) {
522 break;
523 }
524 }
525 }
526
527 // Show cell count
528 int modified_count = 0;
529 for (const auto& cell : current_layout_.cells) {
530 if (cell.modified)
531 ++modified_count;
532 }
533 ImGui::Text(tr("%zu tiles, %d modified"), current_layout_.cells.size(),
534 modified_count);
535}
536
538 if (atlas_dirty_) {
540 }
541
542 ImGui::Text(tr("Source Tiles (Palette %d)"), source_palette_);
543
544 // Palette selector
545 ImGui::SameLine();
546 ImGui::SetNextItemWidth(80);
547 if (ImGui::SliderInt("##SrcPal", &source_palette_, 0, 7)) {
548 atlas_dirty_ = true;
549 }
550
551 constexpr float kAtlasScale = 2.0f;
552 float display_width = zelda3::ObjectTileEditor::kAtlasWidthPx * kAtlasScale;
553 float display_height = zelda3::ObjectTileEditor::kAtlasHeightPx * kAtlasScale;
554
555 ImVec2 atlas_pos = ImGui::GetCursorScreenPos();
556 ImVec2 atlas_size(display_width, display_height);
557
558 // Scrollable child for the atlas
559 ImGui::BeginChild("##AtlasScroll", ImVec2(display_width + 16, 300), true,
560 ImGuiWindowFlags_HorizontalScrollbar);
561
562 atlas_pos = ImGui::GetCursorScreenPos();
563
565 ImGui::Image((ImTextureID)(intptr_t)tile8_atlas_bmp_.texture(), atlas_size);
566 } else {
567 ImGui::Dummy(atlas_size);
568 }
569
570 ImDrawList* draw_list = ImGui::GetWindowDrawList();
571
572 // Draw 8px grid
573 for (int gx = 0; gx <= zelda3::ObjectTileEditor::kAtlasTilesPerRow; ++gx) {
574 float line_x = atlas_pos.x + gx * 8 * kAtlasScale;
575 draw_list->AddLine(ImVec2(line_x, atlas_pos.y),
576 ImVec2(line_x, atlas_pos.y + display_height),
577 IM_COL32(64, 64, 64, 60));
578 }
579 for (int gy = 0; gy <= zelda3::ObjectTileEditor::kAtlasTileRows; ++gy) {
580 float line_y = atlas_pos.y + gy * 8 * kAtlasScale;
581 draw_list->AddLine(ImVec2(atlas_pos.x, line_y),
582 ImVec2(atlas_pos.x + display_width, line_y),
583 IM_COL32(64, 64, 64, 60));
584 }
585
586 // Highlight selected source tile
587 if (selected_source_tile_ >= 0) {
588 int src_col =
590 int src_row =
592 ImVec2 sel_min(atlas_pos.x + src_col * 8 * kAtlasScale,
593 atlas_pos.y + src_row * 8 * kAtlasScale);
594 ImVec2 sel_max(sel_min.x + 8 * kAtlasScale, sel_min.y + 8 * kAtlasScale);
595 draw_list->AddRect(sel_min, sel_max, IM_COL32(0, 255, 255, 255), 0, 0,
596 2.0f);
597 }
598
599 // Handle clicks on the atlas
600 if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(0)) {
601 ImVec2 mouse = ImGui::GetMousePos();
602 int click_col =
603 static_cast<int>((mouse.x - atlas_pos.x) / (8 * kAtlasScale));
604 int click_row =
605 static_cast<int>((mouse.y - atlas_pos.y) / (8 * kAtlasScale));
606
607 if (click_col >= 0 &&
609 click_row >= 0 &&
611 int tile_id =
612 click_row * zelda3::ObjectTileEditor::kAtlasTilesPerRow + click_col;
613 selected_source_tile_ = tile_id;
614
615 // If a cell is selected, replace its tile
616 if (selected_cell_index_ >= 0 &&
618 static_cast<int>(current_layout_.cells.size())) {
620 cell.tile_info.id_ = static_cast<uint16_t>(tile_id);
621 cell.tile_info.palette_ = static_cast<uint8_t>(source_palette_);
622 cell.modified = true;
623 preview_dirty_ = true;
625 }
626 }
627 }
628
629 ImGui::EndChild();
630
631 if (selected_source_tile_ >= 0) {
632 ImGui::Text(tr("Tile: 0x%03X"), selected_source_tile_);
633 }
634}
635
637 if (selected_cell_index_ < 0 ||
638 selected_cell_index_ >= static_cast<int>(current_layout_.cells.size())) {
639 ImGui::TextDisabled(tr("Select a tile cell to edit properties"));
640 return;
641 }
642
644 ImGui::Text(tr("Cell (%d, %d)"), cell.rel_x, cell.rel_y);
645 ImGui::SameLine();
646
647 // Tile ID
648 int tile_id = cell.tile_info.id_;
649 ImGui::SetNextItemWidth(80);
650 if (ImGui::InputInt(tr("ID"), &tile_id, 1, 16)) {
651 cell.tile_info.id_ = static_cast<uint16_t>(tile_id & 0x3FF);
652 cell.modified = true;
653 preview_dirty_ = true;
656 }
657 ImGui::SameLine();
658
659 // Palette
660 int pal = cell.tile_info.palette_;
661 ImGui::SetNextItemWidth(60);
662 if (ImGui::SliderInt(tr("Pal"), &pal, 0, 7)) {
663 cell.tile_info.palette_ = static_cast<uint8_t>(pal);
664 cell.modified = true;
665 preview_dirty_ = true;
668 }
669 ImGui::SameLine();
670
671 // Flip flags
672 if (ImGui::Checkbox(tr("H"), &cell.tile_info.horizontal_mirror_)) {
673 cell.modified = true;
674 preview_dirty_ = true;
676 }
677 ImGui::SameLine();
678 if (ImGui::Checkbox(tr("V"), &cell.tile_info.vertical_mirror_)) {
679 cell.modified = true;
680 preview_dirty_ = true;
682 }
683 ImGui::SameLine();
684 if (ImGui::Checkbox(tr("Pri"), &cell.tile_info.over_)) {
685 cell.modified = true;
686 preview_dirty_ = true;
688 }
689}
690
693 return tile_editor_->WriteBack(current_layout_);
694 }
695
696 auto plan_or = tile_editor_->BuildStandardWritePlan(current_layout_);
697 if (!plan_or.ok()) {
698 return plan_or.status();
699 }
700
701 const auto& plan = *plan_or;
702 if (standard_write_preflight_ && !plan.write_ranges().empty()) {
703 const absl::Status preflight =
704 standard_write_preflight_(plan.write_ranges());
705 if (!preflight.ok()) {
706 return preflight;
707 }
708 }
709
710 return tile_editor_->ApplyStandardWritePlan(plan);
711}
712
713void ObjectTileEditorPanel::ApplyChanges(bool confirm_shared) {
714 // Resolve the complete source impact before every standard write, including
715 // the second call after the user accepts the shared-data confirmation. A
716 // malformed or unresolved object family must block the write rather than be
717 // interpreted as an unshared source.
718 const auto impact_snapshot_or = AnalyzeSourceImpactSnapshot();
719 if (!impact_snapshot_or.ok()) {
720 show_shared_confirm_ = false;
725 absl::StrFormat(ICON_MD_ERROR
726 " Apply blocked: source impact could not be resolved: "
727 "%s",
728 impact_snapshot_or.status().message()));
729 return;
730 }
731 const SourceImpactSnapshot impact_snapshot = *impact_snapshot_or;
732 const int shared_count = impact_snapshot.consumer_count;
733 if (confirm_shared && shared_count > 1) {
734 shared_object_count_ = shared_count;
735 pending_shared_confirmation_ = impact_snapshot;
739 absl::StrFormat(ICON_MD_WARNING
740 " Confirm global apply: %d consumers use this tile "
741 "data. This edit is not covered by Ctrl+Z.",
742 shared_count));
743 return;
744 }
745 const bool missing_required_confirmation =
746 shared_count > 1 && !pending_shared_confirmation_.has_value();
747 const bool confirmed_impact_changed =
748 pending_shared_confirmation_.has_value() &&
749 *pending_shared_confirmation_ != impact_snapshot;
750 if (!confirm_shared &&
751 (missing_required_confirmation || confirmed_impact_changed)) {
752 shared_object_count_ = shared_count;
753 pending_shared_confirmation_ = impact_snapshot;
757 absl::StrFormat(
759 " Source impact changed; review and confirm the updated global "
760 "apply for %d consumers. This edit is not covered by Ctrl+Z.",
761 shared_count));
762 return;
763 }
764
765 show_shared_confirm_ = false;
768
769 auto status = WriteBackCurrentLayout();
770 if (status.ok()) {
772 if (rooms_ != nullptr) {
774 [](int, zelda3::Room& room) { room.MarkObjectsDirty(); });
775 }
778 }
779 }
780
781 // Re-render the current room immediately. Other materialized rooms were
782 // marked dirty above and refresh lazily when their canvases draw.
784 auto& room = (*rooms_)[current_room_id_];
785 room.MarkObjectsDirty();
786 room.RenderRoomGraphics();
787 }
788
789 // Update both the cells and the authoritative source snapshot only after
790 // the write succeeds. The next impact analysis reuses BuildStandardWritePlan
791 // as its CAS gate, so leaving expected_words stale here would make every
792 // later apply fail until the panel was reopened.
793 for (auto& cell : current_layout_.cells) {
794 if (cell.modified) {
795 const uint16_t applied_word = gfx::TileInfoToWord(cell.tile_info);
796 cell.original_word = applied_word;
799 cell.source_ref.has_value()) {
800 const auto& source_ref = *cell.source_ref;
801 auto& spans = current_layout_.source_provenance->spans;
802 if (source_ref.span_index < spans.size() &&
803 source_ref.word_index <
804 spans[source_ref.span_index].expected_words.size()) {
805 spans[source_ref.span_index].expected_words[source_ref.word_index] =
806 applied_word;
807 }
808 }
809 cell.modified = false;
810 }
811 }
812
813 // Successful first save should always exit new-object mode. If the caller
814 // wired a callback, notify it exactly once as part of that transition.
815 if (is_new_object_) {
816 if (on_object_created_) {
819 }
820 is_new_object_ = false;
821 }
822
824 if (shared_count > 1) {
827 absl::StrFormat(
829 " Applied changes to shared tile data used by %d consumers.",
830 shared_count));
831 } else {
833 }
834 return;
835 }
836
839 absl::StrFormat(ICON_MD_ERROR " Apply failed: %s", status.message()));
840}
841
843 const auto& theme = gui::ThemeManager::Get().GetCurrentTheme();
844 int modified_count = 0;
845 for (const auto& cell : current_layout_.cells) {
846 if (cell.modified)
847 ++modified_count;
848 }
849
850 bool has_mods = modified_count > 0;
851
852 if (has_mods) {
853 ImGui::Text(tr("%d tile(s) modified"), modified_count);
854 ImGui::SameLine();
855 }
856
857 // Shared tile data warning. Keep analysis errors visible here, but enforce
858 // fail-closed behavior in ApplyChanges so the disabled-by-default standard
859 // editor cannot silently turn an unknown impact into a write.
860 const auto shared_count_or = GetDisplayedSharedTileDataUsageCount();
861 if (!shared_count_or.ok()) {
862 ImGui::TextColored(gui::ConvertColorToImVec4(theme.error),
863 ICON_MD_ERROR " Source impact unavailable");
864 if (ImGui::IsItemHovered()) {
865 ImGui::SetItemTooltip("%s", shared_count_or.status().message().data());
866 }
867 ImGui::SameLine();
868 } else if (*shared_count_or > 1) {
869 const int shared_count = *shared_count_or;
870 ImGui::TextColored(gui::ConvertColorToImVec4(theme.warning),
871 ICON_MD_WARNING " Shared by %d consumers", shared_count);
872 if (ImGui::IsItemHovered()) {
873 ImGui::SetItemTooltip(
874 tr("This object reuses tile data with %d consumers.\nApplying "
875 "changes "
876 "will update every object or runtime consumer in that shared "
877 "data group.\nThis global ROM edit is not covered by Ctrl+Z."),
878 shared_count);
879 }
880 ImGui::SameLine();
881 }
882
883 // Apply button
884 if (!has_mods)
885 ImGui::BeginDisabled();
886 if (ImGui::Button(ICON_MD_SAVE " Apply")) {
887 ApplyChanges();
888 }
889 if (!has_mods)
890 ImGui::EndDisabled();
891
892 ImGui::SameLine();
893
894 // Revert button
895 if (!has_mods)
896 ImGui::BeginDisabled();
897 if (ImGui::Button(ICON_MD_UNDO " Revert")) {
899 preview_dirty_ = true;
902 }
903 if (!has_mods)
904 ImGui::EndDisabled();
905
906 ImGui::SameLine();
907
908 const char* close_label =
909 has_mods ? ICON_MD_CLOSE " Discard & Close" : ICON_MD_CLOSE " Close";
910 if (ImGui::Button(close_label)) {
911 if (p_open != nullptr) {
912 *p_open = false;
913 }
914 Close();
915 return;
916 }
917 if (has_mods && ImGui::IsItemHovered()) {
918 ImGui::SetItemTooltip(
919 "%s", tr("Close the panel and discard all unapplied tile changes."));
920 }
921
923 ImGui::Spacing();
924 ImGui::TextDisabled(ICON_MD_INFO
925 " Global ROM tile edit; not covered by Ctrl+Z.");
926 }
927
929 !action_status_message_.empty()) {
930 ImVec4 status_color = gui::ConvertColorToImVec4(theme.text_secondary);
931 switch (action_status_tone_) {
933 status_color = gui::ConvertColorToImVec4(theme.warning);
934 break;
936 status_color = gui::ConvertColorToImVec4(theme.success);
937 break;
939 status_color = gui::ConvertColorToImVec4(theme.error);
940 break;
942 break;
943 }
944
945 ImGui::Spacing();
946 ImGui::PushStyleColor(ImGuiCol_Text, status_color);
947 ImGui::PushTextWrapPos(0.0f);
948 ImGui::TextUnformatted(action_status_message_.c_str());
949 ImGui::PopTextWrapPos();
950 ImGui::PopStyleColor();
951 }
952}
953
956 if (p_open != nullptr) {
957 *p_open = false;
958 }
961 " Unapplied tile changes were kept. Reopen the panel to continue, or "
962 "use Discard & Close to throw them away.");
963 return;
964 }
965
966 if (p_open != nullptr) {
967 *p_open = false;
968 }
969 Close();
970}
971
973 // Only handle shortcuts when this window is focused
974 if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows))
975 return;
976 if (current_layout_.cells.empty())
977 return;
978
979 int cell_count = static_cast<int>(current_layout_.cells.size());
980
981 // Arrow keys: navigate selected cell by spatial position
982 auto find_neighbor = [&](int dx, int dy) -> int {
983 if (selected_cell_index_ < 0)
984 return 0;
985 const auto& cur = current_layout_.cells[selected_cell_index_];
986 int target_x = cur.rel_x + dx;
987 int target_y = cur.rel_y + dy;
988 for (int i = 0; i < cell_count; ++i) {
989 if (current_layout_.cells[i].rel_x == target_x &&
990 current_layout_.cells[i].rel_y == target_y) {
991 return i;
992 }
993 }
995 };
996
997 if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) {
998 selected_cell_index_ = find_neighbor(-1, 0);
1000 }
1001 if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) {
1002 selected_cell_index_ = find_neighbor(1, 0);
1004 }
1005 if (ImGui::IsKeyPressed(ImGuiKey_UpArrow, false)) {
1006 selected_cell_index_ = find_neighbor(0, -1);
1008 }
1009 if (ImGui::IsKeyPressed(ImGuiKey_DownArrow, false)) {
1010 selected_cell_index_ = find_neighbor(0, 1);
1012 }
1013
1014 // Number keys 0-7: set palette on selected cell
1015 if (selected_cell_index_ >= 0 && selected_cell_index_ < cell_count) {
1017 for (int key = 0; key <= 7; ++key) {
1018 if (ImGui::IsKeyPressed(static_cast<ImGuiKey>(ImGuiKey_0 + key), false)) {
1019 cell.tile_info.palette_ = static_cast<uint8_t>(key);
1020 cell.modified = true;
1021 preview_dirty_ = true;
1024 }
1025 }
1026
1027 // H: toggle horizontal flip
1028 if (ImGui::IsKeyPressed(ImGuiKey_H, false)) {
1029 cell.tile_info.horizontal_mirror_ = !cell.tile_info.horizontal_mirror_;
1030 cell.modified = true;
1031 preview_dirty_ = true;
1033 }
1034
1035 // V: toggle vertical flip
1036 if (ImGui::IsKeyPressed(ImGuiKey_V, false)) {
1037 cell.tile_info.vertical_mirror_ = !cell.tile_info.vertical_mirror_;
1038 cell.modified = true;
1039 preview_dirty_ = true;
1041 }
1042
1043 // P: toggle priority
1044 if (ImGui::IsKeyPressed(ImGuiKey_P, false)) {
1045 cell.tile_info.over_ = !cell.tile_info.over_;
1046 cell.modified = true;
1047 preview_dirty_ = true;
1049 }
1050 }
1051
1052 // Escape: deselect or close
1053 if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
1054 if (selected_cell_index_ >= 0) {
1056 } else {
1057 RequestSafeWindowClose(p_open);
1058 }
1059 return;
1060 }
1061
1062 // Tab: cycle to next cell
1063 if (ImGui::IsKeyPressed(ImGuiKey_Tab, false)) {
1064 if (cell_count > 0) {
1065 selected_cell_index_ = (selected_cell_index_ + 1) % cell_count;
1067 }
1068 }
1069}
1070
1071} // namespace editor
1072} // 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
StandardTilesAppliedCallback on_standard_tiles_applied_
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)
ObjectTileEditorPanel(gfx::IRenderer *renderer, Rom *rom)
std::unique_ptr< zelda3::ObjectTileEditor > tile_editor_
std::function< void(int, const std::string &) on_object_created_)
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 OpenForNewObject(int width, int height, const std::string &filename, int16_t object_id, int room_id, DungeonRoomStore *rooms)
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_
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:67
const uint8_t * data() const
Definition bitmap.h:398
TextureHandle texture() const
Definition bitmap.h:401
bool is_active() const
Definition bitmap.h:405
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
const Theme & GetCurrentTheme() const
static ThemeManager & Get()
static constexpr int kAtlasTilesPerRow
static bool IsEditableStandardObject(int16_t object_id)
void MarkObjectsDirty()
Definition room.h:409
#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_ADD_BOX
Definition icons.h:90
#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
uint16_t TileInfoToWord(TileInfo tile_info)
Definition snes_tile.cc:361
ImVec4 ConvertColorToImVec4(const Color &color)
Definition color.h:134
std::string GetObjectName(int object_id)
Represents a group of palettes.
std::optional< ObjectTileSourceProvenance > source_provenance
static ObjectTileLayout CreateEmpty(int width, int height, int16_t object_id, const std::string &filename)