yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_editor_v2.cc
Go to the documentation of this file.
1// Related header
2#include "dungeon_editor_v2.h"
3#include "util/i18n/tr.h"
4
5// C system headers
6#include <cctype>
7#include <cstdio>
8
9// C++ standard library headers
10#include <algorithm>
11#include <array>
12#include <iterator>
13#include <memory>
14#include <optional>
15#include <string>
16#include <tuple>
17#include <utility>
18#include <vector>
19
20// Third-party library headers
21#include "absl/status/status.h"
22#include "absl/strings/str_format.h"
23#include "absl/types/span.h"
24#include "imgui/imgui.h"
25
26// Project headers
66#include "app/gui/core/icons.h"
68#include "core/features.h"
69#include "core/project.h"
70#include "rom/snes.h"
71#include "util/log.h"
72#include "util/macro.h"
79#include "zelda3/dungeon/room.h"
82
83namespace yaze::editor {
84
85namespace {
86
88 return std::make_tuple(object.id_, object.x_, object.y_, object.size_,
89 object.GetLayerValue(), object.all_bgs_, object.lit_,
90 object.options(), object.block_behavior_layer(),
91 object.torch_reserved_bit());
92}
93
101
103 return project != nullptr && project->hack_manifest.loaded() &&
104 project->hack_manifest.minecart_track_layout().source.has_value();
105}
106
107// Dungeon tools are hosted by the Workbench inspector's tool drawer. They also
108// open standalone from shortcuts and canvas actions, and from the command
109// palette.
110WindowPresentationPolicy WorkbenchTool(std::string capability = std::string()) {
111 return WindowPresentationPolicy::EmbeddedTool(std::move(capability))
112 .WithHost("Dungeon Workbench inspector");
113}
114
115} // namespace
116
119 // Dependency rebinding is the project-reload boundary even when the project
120 // object is reused in place with a newly loaded manifest.
123 const absl::Status status =
125 if (!status.ok()) {
126 LOG_WARN("DungeonEditorV2", "Minecart project rebind failed: %s",
127 status.message());
128 }
130 }
131}
132
134 if (minecart_track_editor_panel_ == nullptr) {
135 return;
136 }
137
138 auto* editor_manager = static_cast<EditorManager*>(dependencies_.custom_data);
139 const size_t session_id = dependencies_.session_id;
141 [this, editor_manager, session_id](const MinecartTrack& before,
142 const MinecartTrack& after,
143 bool move_cart) {
144 if (editor_manager &&
145 !editor_manager->IsCurrentProjectContextOwnedBySession(session_id))
146 return absl::FailedPreconditionError(
147 "Activate this dungeon project before editing tracks");
148 return ApplyMinecartTrackEdit(before, after, move_cart);
149 });
151 return object_selector_panel_ &&
153 });
155 [this, editor_manager, session_id](
156 int room, const zelda3::CustomCollisionMap& expected,
157 const MinecartCollisionStamp& stamp) -> absl::Status {
158 if (editor_manager &&
159 !editor_manager->IsCurrentProjectContextOwnedBySession(
160 session_id)) {
161 return absl::FailedPreconditionError(
162 "Activate this dungeon project before editing rails");
163 }
164 return ApplyMinecartCollisionStamp(room, expected, stamp);
165 });
167 [this, editor_manager, session_id](
168 const std::vector<zelda3::TrackCollisionResult>& preview,
169 const zelda3::GeneratorOptions& options) -> absl::Status {
170 if (editor_manager != nullptr &&
171 !editor_manager->IsCurrentProjectContextOwnedBySession(
172 session_id)) {
173 return absl::FailedPreconditionError(
174 "Minecart collision apply requires its project context to be "
175 "active");
176 }
177 return ApplyMinecartCollisionBatch(preview, options);
178 });
179 if (editor_manager == nullptr) {
183 return;
184 }
185
187 [editor_manager, session_id](
188 const project::DungeonOverlaySettings& overlay) -> absl::Status {
189 if (!editor_manager->IsCurrentProjectContextOwnedBySession(
190 session_id)) {
191 return absl::FailedPreconditionError(
192 "Minecart overlay edit requires its project context to be "
193 "active");
194 }
195 editor_manager->GetCurrentProject()->dungeon_overlay = overlay;
196 editor_manager->MarkCurrentProjectDirty();
197 return absl::OkStatus();
198 });
200 [editor_manager, session_id]() -> absl::Status {
201 if (!editor_manager->IsCurrentProjectContextOwnedBySession(
202 session_id)) {
203 return absl::FailedPreconditionError(
204 "Minecart overlay draft requires its project context to be "
205 "active");
206 }
207 editor_manager->MarkCurrentProjectDirty();
208 return absl::OkStatus();
209 });
211 [editor_manager, session_id]() -> absl::Status {
212 if (!editor_manager->IsCurrentProjectContextOwnedBySession(
213 session_id)) {
214 return absl::FailedPreconditionError(
215 "Minecart project save requires its project context to be "
216 "active");
217 }
218 return editor_manager->SaveProject();
219 });
220}
221
223 if (minecart_track_editor_panel_ != nullptr) {
224 return absl::OkStatus();
225 }
226 if (!ProjectAdvertisesMinecartTracks(dependencies_.project)) {
227 return absl::FailedPreconditionError(
228 "Minecart Tracks requires hack_manifest.minecart_tracks.source");
229 }
230 if (!core::FeatureFlags::get().kEnableCustomObjects &&
231 dependencies_.toast_manager != nullptr) {
233 "Custom Objects is disabled. Minecart Tracks remains available, but "
234 "custom-object ROM writes stay protected until the flag is enabled.",
236 }
237 if (dependencies_.window_manager != nullptr &&
240 return absl::FailedPreconditionError(
241 "Minecart Tracks can only be registered for the active project "
242 "session");
243 }
244
245 auto minecart_panel = std::make_unique<MinecartTrackEditorPanel>();
246 RETURN_IF_ERROR(minecart_panel->SetProject(dependencies_.project));
247 minecart_panel->SetRooms(&rooms_);
248 minecart_panel->SetRoomNavigationCallback(
249 [this](int room_id) { OnRoomSelected(room_id); });
250
251 minecart_track_editor_panel_ = minecart_panel.get();
253 if (dependencies_.window_manager != nullptr) {
255 std::move(minecart_panel), WorkbenchTool("minecart_tracks"));
256 } else {
257 owned_minecart_track_editor_panel_ = std::move(minecart_panel);
258 }
259
260 room_viewers_.ForEach(
261 [this](int, std::unique_ptr<DungeonCanvasViewer>& viewer) {
262 if (viewer != nullptr) {
263 viewer->SetMinecartTrackPanel(minecart_track_editor_panel_);
264 }
265 });
266 if (workbench_viewer_ != nullptr) {
267 workbench_viewer_->SetMinecartTrackPanel(minecart_track_editor_panel_);
268 }
269 if (workbench_compare_viewer_ != nullptr) {
270 workbench_compare_viewer_->SetMinecartTrackPanel(
272 }
273 if (workbench_panel_ != nullptr) {
278 }
279
280 return absl::OkStatus();
281}
282
284 const bool custom_objects_enabled =
286 const bool custom_objects_state_changed =
287 custom_objects_enabled != observed_custom_objects_enabled_;
288 const uint64_t generation =
290 if (generation == observed_custom_object_generation_ &&
291 !custom_objects_state_changed) {
292 return;
293 }
294
296 if (custom_objects_state_changed) {
298 }
299
301 room.MarkObjectsDirty();
302 room.MarkLayoutDirty();
303 });
304 // Reload Assets also refreshes external sprite art in every session-owned
305 // viewer, including hidden room windows and the comparison canvas.
306 const auto invalidate_sprite_resources = [](DungeonCanvasViewer* viewer) {
307 if (viewer != nullptr) {
308 viewer->InvalidateExternalSpriteResources();
309 }
310 };
311 room_viewers_.ForEach(
312 [&](int, auto& viewer) { invalidate_sprite_resources(viewer.get()); });
313 invalidate_sprite_resources(workbench_viewer_.get());
314 invalidate_sprite_resources(workbench_compare_viewer_.get());
315 if (object_selector_panel_ != nullptr) {
318 }
320 observed_custom_objects_enabled_ = custom_objects_enabled;
321}
322
323namespace {
324
325bool IsTransientDungeonRoomWindowId(const std::string& card_id) {
326 constexpr const char* kPrefix = "dungeon.room_";
327 constexpr size_t kPrefixLength = 13;
328 if (card_id.rfind(kPrefix, 0) != 0 || card_id.size() <= kPrefixLength) {
329 return false;
330 }
331 return std::all_of(card_id.begin() + kPrefixLength, card_id.end(),
332 [](unsigned char ch) { return std::isdigit(ch) != 0; });
333}
334
335bool IsWorkbenchNavigationWindowId(const std::string& card_id) {
336 constexpr std::array<const char*, 3> kWorkbenchNavigationPanelIds = {
338 "dungeon.entrance_properties",
340 };
341
342 return IsTransientDungeonRoomWindowId(card_id) ||
343 std::any_of(kWorkbenchNavigationPanelIds.begin(),
344 kWorkbenchNavigationPanelIds.end(),
345 [&card_id](const char* navigation_id) {
346 return card_id == navigation_id;
347 });
348}
349
350} // namespace
351
353 : rom_(rom), room_loader_(rom), room_selector_(rom), rooms_(rom) {
355 if (rom) {
357 }
358}
359
368
372 if (room_matrix_panel_) {
374 room_matrix_panel_ = nullptr;
375 }
376 // Clear runtime references in panels BEFORE editor-owned rooms and viewers are
377 // destroyed. WorkspaceWindowManager panels may outlive this editor session.
380 room_graphics_panel_ = nullptr;
381 }
387 }
390 object_selector_panel_ = nullptr;
391 }
392 if (door_editor_panel_) {
394 door_editor_panel_ = nullptr;
395 }
398 sprite_editor_panel_ = nullptr;
399 }
400 if (item_editor_panel_) {
402 item_editor_panel_ = nullptr;
403 }
407 custom_collision_panel_ = nullptr;
408 }
409 if (water_fill_panel_) {
412 water_fill_panel_ = nullptr;
413 }
417 }
418}
419
421 if (!rom_ || !rom_->is_loaded()) {
422 return absl::FailedPreconditionError("ROM not loaded");
423 }
424
427
428 std::array<zelda3::RoomEntrance, zelda3::kNumDungeonEntranceSlots>
429 refreshed_entrances;
430 std::array<zelda3::DungeonSpawnPoint, zelda3::kNumDungeonSpawnPoints>
431 refreshed_spawn_points;
432 RETURN_IF_ERROR(room_loader_.LoadRoomEntrances(refreshed_entrances));
433 RETURN_IF_ERROR(room_loader_.LoadDungeonSpawnPoints(refreshed_spawn_points));
434
435 struct RefreshedRoom {
436 int room_id;
437 zelda3::Room room;
438 };
439 std::vector<std::pair<int, bool>> materialized_rooms;
441 [&materialized_rooms](int room_id, const zelda3::Room& room) {
442 materialized_rooms.emplace_back(room_id, room.IsLoaded());
443 });
444
445 std::vector<RefreshedRoom> refreshed_rooms;
446 refreshed_rooms.reserve(materialized_rooms.size());
447 for (const auto& [room_id, was_loaded] : materialized_rooms) {
448 zelda3::Room refreshed_room;
449 if (was_loaded) {
450 RETURN_IF_ERROR(room_loader_.LoadRoom(room_id, refreshed_room));
451 } else {
452 refreshed_room = zelda3::LoadRoomHeaderFromRom(rom_, room_id);
453 refreshed_room.SetGameData(game_data_);
454 }
455 refreshed_rooms.push_back({room_id, std::move(refreshed_room)});
456 }
457
458 // All ROM reads succeeded. Clear every interaction that can retain indices
459 // into Room vectors before replacing any Room contents.
463 }
466 }
467 auto prepare_viewer = [this](DungeonCanvasViewer* viewer, int room_id) {
468 if (viewer) {
469 viewer->RefreshRomBackedState(rom_, game_data_, &rooms_, room_id);
470 }
471 };
472 room_viewers_.ForEach(
473 [&prepare_viewer](int room_id,
474 std::unique_ptr<DungeonCanvasViewer>& viewer) {
475 prepare_viewer(viewer.get(), room_id);
476 });
477 prepare_viewer(workbench_viewer_.get(), current_room_id_);
478 const int compare_room_id =
480 IsValidRoomId(workbench_compare_viewer_->current_room_id())
481 ? workbench_compare_viewer_->current_room_id()
483 prepare_viewer(workbench_compare_viewer_.get(), compare_room_id);
484
486 dungeon_editor_system_->ClearHistory();
487 if (auto object_editor = dungeon_editor_system_->GetObjectEditor()) {
488 (void)object_editor->ClearSelection();
489 object_editor->ClearHistory();
490 }
491 }
494 pending_undo_ = {};
495 has_pending_undo_ = false;
500 pending_swap_ = {};
504
508 for (auto& refreshed : refreshed_rooms) {
509 auto* room = rooms_.GetIfMaterialized(refreshed.room_id);
510 if (room) {
511 *room = std::move(refreshed.room);
512 }
513 }
514 entrances_ = std::move(refreshed_entrances);
515 spawn_points_ = std::move(refreshed_spawn_points);
518
523
526 auto* current_room = IsValidRoomId(current_room_id_)
528 : nullptr;
529 RETURN_IF_ERROR(dungeon_editor_system_->RefreshRomBackedState(
530 current_room, current_room_id_));
531 }
532
537 if (auto* current_room = rooms_.GetIfMaterialized(current_room_id_);
538 current_room && game_data_ &&
540 const int resolved_palette_id = current_room->ResolveDungeonPaletteId();
542 resolved_palette_id >= 0 &&
543 resolved_palette_id <
544 static_cast<int>(
546 ? static_cast<uint64_t>(resolved_palette_id)
547 : 0;
553 }
554
555 if (is_loaded_) {
559 }
560
561 auto bind_viewer = [this](DungeonCanvasViewer* viewer, int room_id) {
562 if (!viewer) {
563 return;
564 }
565 RefreshViewerRuntimeContext(viewer, room_id, /*rebind_project=*/true);
567 };
568 room_viewers_.ForEach(
569 [&bind_viewer](int room_id,
570 std::unique_ptr<DungeonCanvasViewer>& viewer) {
571 bind_viewer(viewer.get(), room_id);
572 });
573 bind_viewer(workbench_viewer_.get(), current_room_id_);
574 bind_viewer(workbench_compare_viewer_.get(), compare_room_id);
575
581 }
584 }
587 }
590 }
594 }
597 }
598 if (workbench_panel_) {
600 }
601
602 DungeonCanvasViewer* current_viewer = nullptr;
604 current_viewer = workbench_viewer_.get();
605 } else if (auto* viewer = room_viewers_.Get(current_room_id_)) {
606 current_viewer = viewer->get();
607 }
608 if (current_viewer) {
610 }
611
612 // This editor-level refresh is used for same-address ROM replacement and
613 // revert. Ordinary room navigation refreshes only its canvas viewer.
615 return absl::OkStatus();
616}
617
620 room.ClearWaterFillZone();
621 room.ClearWaterFillDirty();
622 });
623
624 bool legacy_imported = false;
625 const bool water_fill_save_enabled =
627 std::vector<zelda3::WaterFillZoneEntry> zones;
628
629 auto zones_or = zelda3::LoadWaterFillTable(rom_);
630 if (zones_or.ok()) {
631 zones = std::move(zones_or.value());
632 } else {
633 LOG_WARN("DungeonEditorV2", "WaterFillTable parse failed: %s",
634 zones_or.status().message().data());
637 absl::StrFormat("WaterFill table parse failed: %s",
638 zones_or.status().message()),
640 }
641 }
642
643 if (zones.empty()) {
644 std::string sym_path;
649 }
650 auto legacy_or = zelda3::LoadLegacyWaterGateZones(rom_, sym_path);
651 if (legacy_or.ok()) {
652 zones = std::move(legacy_or.value());
653 legacy_imported = !zones.empty();
654 } else {
655 LOG_WARN("DungeonEditorV2", "Legacy water gate import failed: %s",
656 legacy_or.status().message().data());
657 }
658 }
659
660 for (const auto& z : zones) {
661 if (z.room_id < 0 || z.room_id >= static_cast<int>(rooms_.size())) {
662 continue;
663 }
664 auto& room = rooms_[z.room_id];
665 room.set_water_fill_sram_bit_mask(z.sram_bit_mask);
666 for (uint16_t off : z.fill_offsets) {
667 const int x = static_cast<int>(off % 64);
668 const int y = static_cast<int>(off / 64);
669 room.SetWaterFillTile(x, y, true);
670 }
671
672 // A passive legacy import is pending migration only when this project is
673 // allowed to write the WaterFill table. Otherwise retain the overlay as
674 // read-only reference data without creating unsavable room dirtiness.
675 if (!legacy_imported || !water_fill_save_enabled) {
676 room.ClearWaterFillDirty();
677 }
678 }
679
680 if (legacy_imported && dependencies_.toast_manager) {
682 water_fill_save_enabled
683 ? "Imported legacy water gate zones (save to write new table)"
684 : "Loaded legacy water gate zones read-only (WaterFill saving is "
685 "disabled)",
687 }
688}
689
693 }
695 const bool rom_changed = dependencies_.rom && dependencies_.rom != rom_;
696 if (rom_changed) {
698 // The system owns ROM-backed views; ensure it matches the current ROM.
700 }
703 if (game_data_) {
705 }
706 }
707
708 // Setup docking class for room windows
709 room_window_class_.DockingAllowUnclassed = true;
710 room_window_class_.DockingAlwaysTabBar = true;
711
713 return;
714 auto* window_manager = dependencies_.window_manager;
715 const auto shortcut_hint =
716 [this](const char* id,
717 std::vector<ImGuiKey> default_keys) -> std::string {
718 if (dependencies_.shortcut_manager != nullptr) {
719 const std::string live =
721 if (!live.empty()) {
722 return live;
723 }
724 }
725 return PrintShortcut(default_keys);
726 };
727
728 // Legacy panel IDs persisted in older layouts/settings.
729 window_manager->RegisterPanelAlias("dungeon.object_tools", kObjectSelectorId);
730 window_manager->RegisterPanelAlias("dungeon.entrances",
731 "dungeon.entrance_properties");
732 window_manager->RegisterPanelAlias(kEntranceListId,
733 "dungeon.entrance_properties");
734
735 // Register panels with WorkspaceWindowManager (no boolean flags - visibility is
736 // managed entirely by WorkspaceWindowManager::ShowPanel/HidePanel/IsPanelVisible)
737 // No default chord: Ctrl+Shift+W is reserved for Close Session. Toggle via
738 // View / Window Browser / command palette instead.
739 window_manager->RegisterPanel(
740 {.card_id = "dungeon.workbench",
741 .display_name = "Dungeon Workbench",
742 .window_title = " Dungeon Workbench",
743 .icon = ICON_MD_WORKSPACES,
744 .category = "Dungeon",
745 .workflow_group = "Core",
747 .shortcut_hint = "",
748 .visibility_flag = nullptr,
749 .priority = 5,
750 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
751 .disabled_tooltip = "Load a ROM to edit dungeon rooms"});
752
753 window_manager->RegisterPanel(
754 {.card_id = kRoomSelectorId,
755 .display_name = "Room List",
756 .window_title = " Room List",
757 .icon = ICON_MD_LIST,
758 .category = "Dungeon",
759 .workflow_group = "Core",
760 .presentation = {.role = WindowPresentationRole::kOptionalPopOut,
761 .default_host = WindowDefaultHost::kWorkspace,
762 .list_in_window_browser = false,
763 .allow_popout = true,
764 .declared = true},
765 .shortcut_hint = shortcut_hint(
766 "dungeon.open_room_list", {ImGuiMod_Ctrl, ImGuiMod_Alt, ImGuiKey_L}),
768 .visibility_flag = nullptr,
769 .priority = 20,
770 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
771 .disabled_tooltip = "Load a ROM to browse dungeon rooms"});
772
773 window_manager->RegisterPanel(
774 {.card_id = "dungeon.entrance_properties",
775 .display_name = "Entrances",
776 .window_title = " Entrances",
777 .icon = ICON_MD_DOOR_FRONT,
778 .category = "Dungeon",
779 .workflow_group = "Core",
781 .shortcut_hint =
782 shortcut_hint("dungeon.open_entrances",
783 {ImGuiMod_Ctrl, ImGuiMod_Shift, ImGuiKey_E}),
785 .visibility_flag = nullptr,
786 .priority = 25,
787 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
788 .disabled_tooltip = "Load a ROM to browse and edit dungeon entrances"});
789
790 window_manager->RegisterPanel(
791 {.card_id = kRoomMatrixId,
792 .display_name = "Room Matrix",
793 .window_title = " Room Matrix",
794 .icon = ICON_MD_GRID_VIEW,
795 .category = "Dungeon",
796 .workflow_group = "Core",
798 .shortcut_hint = shortcut_hint(
799 "dungeon.open_matrix", {ImGuiMod_Ctrl, ImGuiMod_Shift, ImGuiKey_M}),
801 .visibility_flag = nullptr,
802 .priority = 30,
803 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
804 .disabled_tooltip = "Load a ROM to view the room matrix"});
805
806 window_manager->RegisterPanel(
807 {.card_id = kRoomGraphicsId,
808 .display_name = "Room Graphics",
809 .window_title = " Room Graphics",
810 .icon = ICON_MD_IMAGE,
811 .category = "Dungeon",
812 .workflow_group = "Editors",
813 .presentation = WorkbenchTool(),
814 .shortcut_hint =
815 shortcut_hint("dungeon.open_room_graphics",
816 {ImGuiMod_Ctrl, ImGuiMod_Shift, ImGuiKey_G}),
818 .visibility_flag = nullptr,
819 .priority = 50,
820 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
821 .disabled_tooltip = "Load a ROM to view room graphics"});
822
823 window_manager->RegisterPanel(
824 {.card_id = kObjectSelectorId,
825 .display_name = "Object Selector",
826 .window_title = " Object Selector",
827 .icon = ICON_MD_CATEGORY,
828 .category = "Dungeon",
829 .workflow_group = "Editors",
830 .presentation = WorkbenchTool(),
831 .shortcut_hint = "",
832 .visibility_flag = nullptr,
833 .priority = 60,
834 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
835 .disabled_tooltip = "Load a ROM to browse dungeon objects"});
836
837 window_manager->RegisterPanel(
838 {.card_id = kDoorEditorId,
839 .display_name = "Door Editor",
840 .window_title = " Door Editor",
841 .icon = ICON_MD_DOOR_FRONT,
842 .category = "Dungeon",
843 .workflow_group = "Editors",
844 .presentation = WorkbenchTool(),
845 .shortcut_hint = "",
846 .visibility_flag = nullptr,
847 .priority = 69,
848 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
849 .disabled_tooltip = "Load a ROM to edit dungeon doors"});
850
851 window_manager->RegisterPanel(
852 {.card_id = kPaletteEditorId,
853 .display_name = "Palette Editor",
854 .window_title = " Palette Editor",
855 .icon = ICON_MD_PALETTE,
856 .category = "Dungeon",
857 .workflow_group = "Editors",
858 .presentation = WorkbenchTool(),
859 // Avoid conflicting with the global Command Palette (Ctrl/Cmd+Shift+P).
860 .shortcut_hint = shortcut_hint(
861 "dungeon.open_palette",
862 {ImGuiMod_Ctrl, ImGuiMod_Shift, ImGuiMod_Alt, ImGuiKey_P}),
864 .visibility_flag = nullptr,
865 .priority = 70,
866 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
867 .disabled_tooltip = "Load a ROM to edit dungeon palettes"});
868
869 // Show default panels on startup.
870 // Workbench mode intentionally suppresses parallel standalone defaults to
871 // avoid two competing workflows being open at once.
873 /*show_toast=*/false);
874
875 // Wire intent-aware callback for double-click / context menu
877 [this](int room_id, RoomSelectionIntent intent) {
878 OnRoomSelected(room_id, intent);
879 });
880
881 // Register WindowContent instances
882 window_manager->RegisterWindowContent(std::make_unique<RoomBrowserContent>(
883 &room_selector_, [this](int room_id) { OnRoomSelected(room_id); }));
884
885 {
886 auto matrix_panel = std::make_unique<RoomMatrixContent>(
888 [this](int room_id) { OnRoomSelected(room_id); },
889 [this](int old_room, int new_room) {
890 SwapRoomInPanel(old_room, new_room);
891 },
892 &rooms_);
893 matrix_panel->SetRoomIntentCallback(
894 [this](int room_id, RoomSelectionIntent intent) {
895 OnRoomSelected(room_id, intent);
896 });
897 matrix_panel->SetCensusManifestProvider(
898 [this]() -> const core::HackManifest* {
900 : nullptr;
901 });
902 room_matrix_panel_ = matrix_panel.get();
903 window_manager->RegisterWindowContent(std::move(matrix_panel));
904 }
905
906 {
907 auto workbench = std::make_unique<DungeonWorkbenchContent>(
909 [this](int room_id) { OnRoomSelected(room_id); },
910 [this](int room_id, RoomSelectionIntent intent) {
911 OnRoomSelected(room_id, intent);
912 },
913 [this](int room_id) {
914 auto status = SaveRoom(room_id);
915 if (!status.ok()) {
916 LOG_ERROR("DungeonEditorV2", "Apply Room failed: %s",
917 status.message().data());
920 absl::StrFormat("Apply Room failed: %s", status.message()),
922 }
923 return;
924 }
926 dependencies_.toast_manager->Show("Applied room to ROM buffer",
928 }
929 },
930 [this]() { SaveAllRooms(); }, [this]() { return GetWorkbenchViewer(); },
931 [this](int room_id) { return GetWorkbenchCompareViewer(room_id); },
932 [this]() -> const std::deque<int>& { return recent_rooms_; },
933 [this](int room_id) {
934 recent_rooms_.erase(
935 std::remove(recent_rooms_.begin(), recent_rooms_.end(), room_id),
936 recent_rooms_.end());
937 },
938 [this](bool enabled) { QueueWorkbenchWorkflowMode(enabled); }, rom_);
939 workbench_panel_ = workbench.get();
941 [this](DungeonCanvasViewer& viewer) {
943 });
945 [this]() { return undo_manager_.CanUndo(); },
946 [this]() { return undo_manager_.CanRedo(); },
947 [this]() { Undo().IgnoreError(); }, [this]() { Redo().IgnoreError(); },
948 [this]() { return undo_manager_.GetUndoDescription(); },
949 [this]() { return undo_manager_.GetRedoDescription(); },
950 [this]() { return static_cast<int>(undo_manager_.UndoStackSize()); });
952 [window_manager](const std::string& window_id) {
953 const size_t session_id = window_manager->GetActiveSessionId();
954 return window_manager->IsWindowOpen(session_id, window_id);
955 },
956 [this, window_manager](const std::string& window_id) {
957 if (window_id.empty()) {
958 return false;
959 }
961 window_manager->GetActiveSessionId();
964 return true;
965 });
967 [window_manager]() { window_manager->TriggerShowShortcuts(); });
968 workbench_panel_->SetOnInspectorSideChanged([this](bool on_left) {
971 : "right");
972 dependencies_.user_settings->Save().IgnoreError();
973 }
974 });
976 return game_data_ ? &game_data_->pit_damage_table : nullptr;
977 });
978 window_manager->RegisterWindowContent(std::move(workbench));
979 }
980
981 {
982 auto entrances_panel = std::make_unique<DungeonEntrancesPanel>(
984 [this](int entrance_id) { OnEntranceSelected(entrance_id); });
985 entrances_panel->SetGraphicsPreview(
986 [this]() {
988 },
989 [this]() { return automatic_entrance_preview_; },
990 [this](int slot) { SetGraphicsPreviewEntrance(slot); });
991 ConfigureEntrancePropertyEditCallbacks(entrances_panel.get());
992 entrance_navigator_panel_ = entrances_panel.get();
993 window_manager->RegisterWindowContent(std::move(entrances_panel));
994 }
995 if (workbench_panel_) {
999 [this](int room_id) { ApplyEntranceRenderContext(room_id); });
1000 }
1001 if (room_matrix_panel_) {
1003 [this](int room_id) { ApplyEntranceRenderContext(room_id); });
1004 }
1005
1006 // Note: RoomGraphicsContent and PaletteEditorContent are registered
1007 // in Load() after their dependencies (renderer_, palette_editor_) are initialized
1008}
1009
1011 if (!rom_ || !rom_->is_loaded()) {
1012 return absl::FailedPreconditionError("ROM not loaded");
1013 }
1014
1015 // Initialize ObjectDimensionTable so DimensionService's fallback path works.
1016 // DimensionService::Get() queries this table internally but has no Load API.
1017 auto& dim_table = zelda3::ObjectDimensionTable::Get();
1018 if (!dim_table.IsLoaded()) {
1019 RETURN_IF_ERROR(dim_table.LoadFromRom(rom_));
1020 }
1021
1025
1026 if (!game_data()) {
1027 return absl::FailedPreconditionError("GameData not available");
1028 }
1029 auto dungeon_main_pal_group = game_data()->palette_groups.dungeon_main;
1030 current_palette_ = dungeon_main_pal_group[current_palette_group_id_];
1033
1040 [this](int room_id) { OnRoomSelected(room_id); });
1042 [this](int room_id, RoomSelectionIntent intent) {
1043 OnRoomSelected(room_id, intent);
1044 });
1046 [this](int entrance_id) { OnEntranceSelected(entrance_id); });
1047
1048 // Canvas viewers are lazily created in GetViewerForRoom
1049
1050 if (game_data()) {
1052 } else {
1054 }
1055
1058
1059 // Register panels that depend on initialized state (renderer, palette_editor_)
1061 auto graphics_panel = std::make_unique<RoomGraphicsContent>(
1063 graphics_panel->SetAssignmentCallback(
1064 [this](int room, int slot, int sheet) {
1065 return EditRoomGraphicsSheet(room, slot, sheet);
1066 });
1067 graphics_panel->SetUsageCallback(
1068 [this](int roomset) { return GetRoomGraphicsUsage(roomset); });
1069 room_graphics_panel_ = graphics_panel.get();
1071 std::move(graphics_panel), WorkbenchTool());
1072 auto palette_panel =
1073 std::make_unique<PaletteEditorContent>(&palette_editor_);
1074 palette_editor_panel_ = palette_panel.get();
1076 std::move(palette_panel), WorkbenchTool());
1077 }
1078
1079 dungeon_editor_system_ = std::make_unique<zelda3::DungeonEditorSystem>(rom_);
1080 (void)dungeon_editor_system_->Initialize();
1082
1083 // Initialize browse-only object selector and the dedicated object editor.
1084 auto object_selector = std::make_unique<ObjectSelectorContent>(
1085 rom_, nullptr, dungeon_editor_system_->GetObjectEditor(),
1087
1088 // Wire up object change callback to trigger room re-rendering
1089 dungeon_editor_system_->GetObjectEditor()->SetObjectChangedCallback(
1090 [this](size_t /*object_index*/, const zelda3::RoomObject& /*object*/) {
1091 if (current_room_id_ >= 0 && current_room_id_ < (int)rooms_.size()) {
1092 rooms_[current_room_id_].RenderRoomGraphics();
1093 }
1094 });
1095
1096 // Set rooms and initial palette group for correct preview rendering
1097 object_selector->SetRooms(&rooms_);
1098 object_selector->SetCurrentPaletteGroup(current_palette_group_);
1099
1100 auto open_workbench_selection_inspector = [this]() {
1101 if (workbench_panel_) {
1103 }
1104 OpenWindow("dungeon.workbench");
1105 };
1106
1107 object_selector->SetOpenObjectEditorCallback(
1108 open_workbench_selection_inspector);
1109
1110 // Keep raw pointers for later access
1111 object_selector_panel_ = object_selector.get();
1112
1113 auto jump_to_reciprocal_door = [this](int neighbor_room_id,
1114 size_t door_index) {
1115 if (neighbor_room_id < 0 ||
1116 neighbor_room_id >= static_cast<int>(rooms_.size())) {
1117 return;
1118 }
1119 OnRoomSelected(neighbor_room_id, true);
1120 if (auto* viewer = GetViewerForRoom(neighbor_room_id)) {
1121 // Bind before selecting; see NavigateToPlacedObject.
1122 viewer->object_interaction().SetCurrentRoom(&rooms_, neighbor_room_id);
1123 viewer->object_interaction()
1124 .entity_coordinator()
1125 .door_handler()
1126 .SelectDoor(door_index);
1127 }
1128 };
1129 auto door_editor = std::make_unique<DoorEditorContent>();
1130 door_editor->SetRooms(&rooms_);
1131 door_editor->SetOpenSelectionInspectorCallback(
1132 open_workbench_selection_inspector);
1133 door_editor->SetJumpToReciprocalDoorCallback(jump_to_reciprocal_door);
1134 door_editor_panel_ = door_editor.get();
1135
1136 // Propagate game_data to the object editor panel if available
1137 if (game_data()) {
1139 }
1140
1141 // Room-scoped utility tools use the Workbench inspector by default, while the
1142 // same WindowContent instances remain available to users who prefer floating
1143 // windows. OpenTool keeps one presentation owner at a time so one content
1144 // instance is never drawn in both places during the same frame.
1145 auto custom_collision_panel =
1146 std::make_unique<CustomCollisionPanel>(nullptr, nullptr);
1147 custom_collision_panel_ = custom_collision_panel.get();
1148
1149 auto water_fill_panel = std::make_unique<WaterFillPanel>(nullptr, nullptr);
1150 water_fill_panel_ = water_fill_panel.get();
1151
1152 auto room_tag_panel = std::make_unique<RoomTagEditorPanel>();
1153 room_tag_panel->SetProject(dependencies_.project);
1154 room_tag_panel->SetRooms(&rooms_);
1155 room_tag_panel->SetCurrentRoomId(current_room_id_);
1156 room_tag_panel->SetMetadataEditCallback(
1157 [this](int room_id, const RoomMetadataEdit& edit) {
1158 return EditRoomMetadata(room_id, edit);
1159 });
1160 room_tag_editor_panel_ = room_tag_panel.get();
1161
1162 auto object_coverage_panel = CreateObjectCoveragePanel();
1163 object_coverage_panel_ = object_coverage_panel.get();
1164
1165 // Register the ObjectSelectorContent directly (it inherits from WindowContent)
1166 // Panel manager takes ownership
1169 std::move(object_selector), WorkbenchTool());
1170 dependencies_.window_manager->RegisterWindowContent(std::move(door_editor),
1171 WorkbenchTool());
1172
1173 // Register sprite and item editor panels with canvas viewer = nullptr
1174 // They will get the viewer reference in OnRoomSelected when a room is selected
1175 auto sprite_panel = std::make_unique<SpriteEditorPanel>(&current_room_id_,
1176 &rooms_, nullptr);
1177 sprite_panel->SetOpenSelectionInspectorCallback(
1178 open_workbench_selection_inspector);
1179 sprite_editor_panel_ = sprite_panel.get();
1180 dependencies_.window_manager->RegisterWindowContent(std::move(sprite_panel),
1181 WorkbenchTool());
1182
1183 auto item_panel =
1184 std::make_unique<ItemEditorPanel>(&current_room_id_, &rooms_, nullptr);
1185 item_panel->SetOpenSelectionInspectorCallback(
1186 open_workbench_selection_inspector);
1187 item_editor_panel_ = item_panel.get();
1188 dependencies_.window_manager->RegisterWindowContent(std::move(item_panel),
1189 WorkbenchTool());
1190
1192 std::move(custom_collision_panel), WorkbenchTool());
1194 std::move(water_fill_panel), WorkbenchTool());
1196 std::move(room_tag_panel), WorkbenchTool());
1198 std::move(object_coverage_panel),
1200 "Canvas context menu > Check in Object Coverage"));
1201 // Object Tile Editor Panel
1202 {
1203 auto tile_editor_panel =
1204 std::make_unique<ObjectTileEditorPanel>(renderer_, rom_);
1205 tile_editor_panel->SetCurrentPaletteGroup(current_palette_group_);
1206 tile_editor_panel->SetStandardWritePreflightCallback(
1207 [this](const std::vector<std::pair<uint32_t, uint32_t>>& ranges) {
1208 if (ranges.empty() || dependencies_.project == nullptr ||
1210 return absl::OkStatus();
1211 }
1215 "dungeon object tile data", "ObjectTileEditorPanel",
1217 });
1218 tile_editor_panel->SetTilesAppliedCallback([this]() {
1221 }
1222 });
1223
1224 object_tile_editor_panel_ = tile_editor_panel.get();
1226 std::move(tile_editor_panel),
1227 WorkbenchTool().WithHost(
1228 "Object navigator or canvas selection menu > Edit tiles"));
1229 }
1230
1231 // Wire fixed-slot custom-object management actions to their workspace
1232 // windows.
1235 [this](int room_id, const zelda3::RoomObject& object) {
1236 return OpenObjectTileEditorForObject(room_id, object);
1237 });
1241 [this]() {
1242 return dependencies_.window_manager != nullptr &&
1243 object_tile_editor_panel_ != nullptr &&
1246 });
1249 return dependencies_.window_manager != nullptr &&
1250 minecart_track_editor_panel_ != nullptr &&
1253 });
1254 }
1255
1256 // Overlay Manager Panel
1257 {
1258 auto overlay_panel = std::make_unique<OverlayManagerPanel>();
1259 overlay_manager_panel_ = overlay_panel.get();
1261 std::move(overlay_panel),
1262 WorkbenchTool().WithHost("Command palette"));
1263 }
1264
1265 } else {
1266 owned_object_selector_panel_ = std::move(object_selector);
1267 owned_door_editor_panel_ = std::move(door_editor);
1268 owned_custom_collision_panel_ = std::move(custom_collision_panel);
1269 owned_water_fill_panel_ = std::move(water_fill_panel);
1270 owned_room_tag_editor_panel_ = std::move(room_tag_panel);
1271 owned_object_coverage_panel_ = std::move(object_coverage_panel);
1272 }
1273
1274 if (ProjectAdvertisesMinecartTracks(dependencies_.project)) {
1276 }
1277
1279 [this](gui::DungeonPaletteChange change) {
1281 });
1283
1284 // Oracle of Secrets: load editor-authored water fill zones (best-effort).
1286
1287 if (workbench_panel_) {
1295 }
1296
1297 is_loaded_ = true;
1298 return absl::OkStatus();
1299}
1300
1302 if (palette_listener_id_ >= 0) {
1303 return;
1304 }
1305
1307 [this](const std::string& group_name, int palette_index) {
1308 HandleArenaPaletteChanged(group_name, palette_index);
1309 });
1310}
1311
1312void DungeonEditorV2::HandleArenaPaletteChanged(const std::string& group_name,
1313 int palette_index) {
1314 if (!gfx::PaletteManager::Get().IsManaging(game_data_)) {
1315 return;
1316 }
1317
1318 if (group_name == "hud") {
1321 } else if (group_name == "dungeon_main") {
1324 }
1325}
1326
1330
1331 std::array<bool, zelda3::kNumberOfRooms> rendered_rooms{};
1332 auto render_once = [this, &rendered_rooms](int room_id) {
1333 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()) ||
1334 room_id >= static_cast<int>(rendered_rooms.size()) ||
1335 rendered_rooms[room_id]) {
1336 return;
1337 }
1338 rooms_[room_id].RenderRoomGraphics();
1339 rendered_rooms[room_id] = true;
1340 };
1341
1342 for (int i = 0; i < active_rooms_.Size; i++) {
1343 render_once(active_rooms_[i]);
1344 }
1345
1346 render_once(current_room_id_);
1347
1348 auto apply_palette = [](DungeonCanvasViewer* viewer, uint64_t palette_id,
1349 const gfx::PaletteGroup& palette_group,
1350 bool force_shared_palette_refresh) {
1351 if (!viewer) {
1352 return;
1353 }
1354 viewer->SetCurrentPaletteId(palette_id);
1355 viewer->SetCurrentPaletteGroup(
1356 palette_group,
1357 /*force_refresh=*/force_shared_palette_refresh &&
1359 };
1360
1361 if (current_room_id_ >= 0 &&
1362 current_room_id_ < static_cast<int>(rooms_.size()) && game_data()) {
1363 const bool force_shared_palette_refresh =
1365 auto& dungeon_main_pal_group = game_data()->palette_groups.dungeon_main;
1367 static_cast<uint64_t>(dungeon_main_pal_group.size())) {
1369 current_palette_ = dungeon_main_pal_group[current_palette_id_];
1372 game_data());
1373 apply_palette(workbench_viewer_.get(), current_palette_id_,
1374 current_palette_group_, force_shared_palette_refresh);
1376 current_palette_group_, force_shared_palette_refresh);
1377 room_viewers_.ForEach([this, change, &apply_palette,
1378 &dungeon_main_pal_group, &render_once,
1379 force_shared_palette_refresh](
1380 int room_id,
1381 std::unique_ptr<DungeonCanvasViewer>& viewer) {
1382 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
1383 return;
1384 }
1385 const int palette_id = rooms_[room_id].ResolveDungeonPaletteId();
1386 if (palette_id < 0 ||
1387 palette_id >= static_cast<int>(dungeon_main_pal_group.size())) {
1388 return;
1389 }
1391 change.palette_id >= 0 && palette_id != change.palette_id) {
1392 return;
1393 }
1394 const auto room_palette_group =
1396 dungeon_main_pal_group.palette_ref(palette_id), game_data());
1397 if (viewer && viewer->object_interaction().IsObjectLoaded()) {
1398 render_once(room_id);
1399 }
1400 apply_palette(viewer.get(), palette_id, room_palette_group,
1401 force_shared_palette_refresh);
1402 });
1405 }
1408 }
1409 }
1410 }
1411
1412 // Tile edits remain bound to their original room even when the active room
1413 // changes or closes. Refresh that room's palette without reopening its session.
1415 !game_data()) {
1416 return;
1417 }
1418 const int tile_room_id = object_tile_editor_panel_->current_room_id();
1419 const auto* tile_room = rooms_.GetIfLoaded(tile_room_id);
1420 if (!tile_room) {
1421 return;
1422 }
1423 const int tile_palette_id = tile_room->ResolveDungeonPaletteId();
1424 const auto& dungeon_palettes = game_data()->palette_groups.dungeon_main;
1425 if (tile_palette_id < 0 ||
1426 tile_palette_id >= static_cast<int>(dungeon_palettes.size()) ||
1428 change.palette_id >= 0 && tile_palette_id != change.palette_id)) {
1429 return;
1430 }
1432 tile_room_id,
1434 dungeon_palettes.palette_ref(tile_palette_id), game_data()));
1435}
1436
1440 change.palette_id < 0) {
1442 [](int, zelda3::Room& room) { room.MarkGraphicsDirty(); });
1443 return;
1444 }
1445
1446 // Editing palette bytes does not change a room header, so the room's normal
1447 // property cache cannot detect this dependency change. Mark every cached
1448 // room that resolves to the edited concrete palette; inactive rooms will
1449 // redraw lazily the next time they are shown.
1450 rooms_.ForEachMaterialized([change](int, zelda3::Room& room) {
1451 if (room.ResolveDungeonPaletteId() == change.palette_id) {
1452 room.MarkGraphicsDirty();
1453 }
1454 });
1455}
1456
1462
1463 // Mirror the workbench inspector to the LEFT when user_settings says so.
1464 // Persisted across sessions via UserSettings::dungeon_inspector_side.
1468 }
1469
1470 const auto& theme = AgentUI::GetTheme();
1471 if (room_window_class_.ClassId == 0) {
1472 room_window_class_.ClassId = ImGui::GetID("DungeonRoomClass");
1473 }
1474
1475 if (!is_loaded_) {
1476 gui::PanelWindow loading_card("Dungeon Editor Loading", ICON_MD_CASTLE);
1477 loading_card.SetDefaultSize(400, 200);
1478 if (loading_card.Begin()) {
1479 ImGui::TextColored(theme.text_secondary_gray,
1480 tr("Loading dungeon data..."));
1481 ImGui::TextWrapped(
1482 tr("Independent editor cards will appear once ROM data is loaded."));
1483 }
1484 loading_card.End();
1485 return absl::OkStatus();
1486 }
1487
1488 // Poll the async ownership census and propagate a newly resolved entrance
1489 // context before any room or graphics surface draws this frame.
1491
1492 if (!IsWorkbenchWorkflowEnabled() || active_rooms_.Size > 0) {
1494 }
1495
1496 // Process any pending room swaps after all drawing is complete
1497 // This prevents ImGui state corruption from modifying collections mid-frame
1499
1500 return absl::OkStatus();
1501}
1502
1504 if (ImGui::GetCurrentContext() == nullptr) {
1505 return false;
1506 }
1507 bool canvas_owns_focus = false;
1508 room_viewers_.ForEach(
1509 [&canvas_owns_focus](int,
1510 const std::unique_ptr<DungeonCanvasViewer>& viewer) {
1511 canvas_owns_focus =
1512 canvas_owns_focus ||
1513 (viewer != nullptr && viewer->OwnsRoomNavigationShortcutFocus());
1514 });
1515 canvas_owns_focus =
1516 canvas_owns_focus ||
1517 (workbench_viewer_ != nullptr &&
1518 workbench_viewer_->OwnsRoomNavigationShortcutFocus()) ||
1519 (workbench_compare_viewer_ != nullptr &&
1520 workbench_compare_viewer_->OwnsRoomNavigationShortcutFocus());
1521 const bool navigator_owns_focus =
1522 (room_matrix_panel_ != nullptr &&
1524 (entrance_navigator_panel_ != nullptr &&
1526 return canvas_owns_focus || navigator_owns_focus;
1527}
1528
1531 constexpr int kColumns = 16;
1532 int next_room = -1;
1533 switch (direction) {
1535 if (current_room_id_ >= kColumns) {
1536 next_room = current_room_id_ - kColumns;
1537 }
1538 break;
1540 if (IsValidRoomId(current_room_id_ + kColumns)) {
1541 next_room = current_room_id_ + kColumns;
1542 }
1543 break;
1545 if (current_room_id_ % kColumns > 0) {
1546 next_room = current_room_id_ - 1;
1547 }
1548 break;
1550 if (current_room_id_ % kColumns < kColumns - 1 &&
1552 next_room = current_room_id_ + 1;
1553 }
1554 break;
1555 }
1556 if (next_room < 0) {
1557 return false;
1558 }
1560 OnRoomSelected(next_room, /*request_focus=*/false);
1561 } else {
1563 }
1564 return true;
1565}
1566
1568 if (direction == 0) {
1569 return false;
1570 }
1572 if (recent_rooms_.size() <= 1) {
1573 return false;
1574 }
1575 const auto current =
1576 std::find(recent_rooms_.begin(), recent_rooms_.end(), current_room_id_);
1577 if (current == recent_rooms_.end()) {
1578 return false;
1579 }
1580 const int current_index =
1581 static_cast<int>(std::distance(recent_rooms_.begin(), current));
1582 const int count = static_cast<int>(recent_rooms_.size());
1583 const int next_index =
1584 (current_index + (direction > 0 ? 1 : -1) + count) % count;
1585 OnRoomSelected(recent_rooms_[next_index]);
1586 return true;
1587 }
1588
1589 if (active_rooms_.size() <= 1) {
1590 return false;
1591 }
1592 int current_index = -1;
1593 for (int index = 0; index < active_rooms_.size(); ++index) {
1594 if (active_rooms_[index] == current_room_id_) {
1595 current_index = index;
1596 break;
1597 }
1598 }
1599 if (current_index < 0) {
1600 return false;
1601 }
1602 const int count = active_rooms_.size();
1603 const int next_index =
1604 (current_index + (direction > 0 ? 1 : -1) + count) % count;
1605 OnRoomSelected(active_rooms_[next_index]);
1606 return true;
1607}
1608
1610 if (direction == 0 || !IsValidRoomId(current_room_id_)) {
1611 return false;
1612 }
1613 const auto* room = rooms_.GetIfLoaded(current_room_id_);
1614 if (!room || room->GetTileObjects().empty()) {
1615 return false;
1616 }
1617 auto* viewer = GetViewerForRoom(current_room_id_);
1618 if (!viewer || viewer->rooms() != &rooms_ ||
1619 viewer->current_room_id() != current_room_id_ ||
1620 !viewer->IsObjectInteractionEnabled() || viewer->header_read_only()) {
1621 return false;
1622 }
1623 auto& interaction = viewer->object_interaction();
1624 const auto* context =
1625 interaction.entity_coordinator().tile_handler().context();
1626 if (!context || context->current_room_id != current_room_id_) {
1627 return false;
1628 }
1629 const auto selected = interaction.GetSelectedObjectIndices();
1630 const auto& objects = room->GetTileObjects();
1631 const size_t count = objects.size();
1632 const size_t current = selected.empty() ? 0 : selected.front();
1633 const size_t next =
1634 ((current < count ? current : 0) + (direction > 0 ? 1 : count - 1)) %
1635 count;
1636 interaction.SetSelectedObjects({next});
1637 viewer->ScrollToTile(objects[next].x_, objects[next].y_);
1638 return true;
1639}
1640
1642 return rooms_.LoadedCount();
1643}
1644
1646 int count = 0;
1647 rooms_.ForEachMaterialized([&count](int, const zelda3::Room& room) {
1648 if (room.HasUnsavedChanges()) {
1649 ++count;
1650 }
1651 });
1652 return count;
1653}
1654
1656 return PendingRoomCount() > 0;
1657}
1658
1663
1666 return true;
1667 }
1668 if (object_tile_editor_panel_ != nullptr &&
1670 return true;
1671 }
1672 if (HasPendingRoomChanges()) {
1673 return true;
1674 }
1675 if (std::any_of(entrances_.begin(), entrances_.end(),
1676 [](const zelda3::RoomEntrance& entrance) {
1677 return entrance.dirty();
1678 })) {
1679 return true;
1680 }
1681 if (std::any_of(spawn_points_.begin(), spawn_points_.end(),
1682 [](const zelda3::DungeonSpawnPoint& spawn) {
1683 return spawn.dirty();
1684 })) {
1685 return true;
1686 }
1687 return game_data_ != nullptr && game_data_->pit_damage_table.dirty();
1688}
1689
1694
1696 return minecart_track_editor_panel_ != nullptr
1698 : absl::OkStatus();
1699}
1700
1702 if (current_room_id_ < 0 ||
1703 current_room_id_ >= static_cast<int>(rooms_.size())) {
1704 return false;
1705 }
1706 if (const auto* room = rooms_.GetIfMaterialized(current_room_id_)) {
1707 return room->HasUnsavedChanges();
1708 }
1709 return false;
1710}
1711
1713 if (!core::FeatureFlags::get().dungeon.kUseWorkbench) {
1714 return false;
1715 }
1717 return true;
1718 }
1719 return dependencies_.window_manager->IsWindowOpen("dungeon.workbench");
1720}
1721
1722void DungeonEditorV2::SetWorkbenchWorkflowMode(bool enabled, bool show_toast) {
1723 auto* window_manager = dependencies_.window_manager;
1724 if (!window_manager) {
1725 return;
1726 }
1727
1728 const size_t session_id = window_manager->GetActiveSessionId();
1729 const bool was_enabled = IsWorkbenchWorkflowEnabled();
1730
1731 if (enabled) {
1732 window_manager->OpenWindow(session_id, "dungeon.workbench");
1733
1734 if (!was_enabled) {
1736
1737 // The Workbench replaces room navigation, but not standalone editing
1738 // tools. Keep those tool windows open so user-owned HM/ZS-style layouts
1739 // survive workflow switches; the embedded inspector yields ownership to
1740 // any standalone tool that is already visible.
1741 for (const auto& descriptor :
1742 window_manager->GetWindowsInCategory(session_id, "Dungeon")) {
1743 const std::string& card_id = descriptor.card_id;
1744 if (!IsWorkbenchNavigationWindowId(card_id) ||
1745 !window_manager->IsWindowOpen(session_id, card_id) ||
1746 window_manager->IsWindowPinned(session_id, card_id)) {
1747 continue;
1748 }
1750 window_manager->CloseWindow(session_id, card_id);
1751 }
1752 }
1753 } else {
1754 window_manager->CloseWindow(session_id, "dungeon.workbench");
1755 window_manager->OpenWindow(session_id, kRoomMatrixId);
1756 window_manager->OpenWindow(session_id, "dungeon.entrance_properties");
1757 if (current_room_id_ >= 0) {
1759 }
1760 for (const std::string& card_id :
1762 window_manager->OpenWindow(session_id, card_id);
1763 }
1765 }
1766
1767 const bool now_enabled = IsWorkbenchWorkflowEnabled();
1768 if (is_loaded_ && was_enabled != now_enabled &&
1770 // One room model and the same tools serve either canvas host. Rebind all
1771 // room-scoped panel pointers before closed standalone viewers are retired
1772 // by DrawRoomPanels on the next frame.
1774 }
1775
1776 if (show_toast && dependencies_.toast_manager && was_enabled != now_enabled) {
1778 now_enabled ? "Dungeon workflow: Workbench"
1779 : "Dungeon workflow: Standalone Panels",
1781 }
1782}
1783
1785 bool show_toast) {
1789}
1790
1794
1796 for (int i = 0; i < active_rooms_.Size; i++) {
1797 int room_id = active_rooms_[i];
1798 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
1799 bool panel_visible = true;
1801 panel_visible = dependencies_.window_manager->IsWindowOpen(card_id);
1802 }
1803
1804 if (!panel_visible) {
1806 room_cards_.erase(room_id);
1807 active_rooms_.erase(active_rooms_.Data + i);
1808 ReleaseRoomPanelSlotId(room_id);
1809 // Clean up viewer
1810 room_viewers_.Erase(room_id);
1811 i--;
1812 continue;
1813 }
1814
1815 bool is_pinned = dependencies_.window_manager &&
1817 std::string active_category =
1820 : "";
1821
1822 if (active_category != "Dungeon" && !is_pinned) {
1823 continue;
1824 }
1825
1826 // Ensure room card exists (should have been created by ShowRoomPanel/ShowPanel)
1827 if (room_cards_.find(room_id) == room_cards_.end()) {
1828 ShowRoomPanel(room_id);
1829 }
1830
1831 auto& room_card = room_cards_[room_id];
1832 bool open = true;
1833
1834 ImGui::SetNextWindowClass(&room_window_class_);
1835 if (room_dock_id_ == 0) {
1836 room_dock_id_ = ImGui::GetID("DungeonRoomDock");
1837 }
1838 ImGui::SetNextWindowDockID(room_dock_id_, ImGuiCond_FirstUseEver);
1839
1840 if (room_card->Begin(&open)) {
1841 if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) {
1842 OnRoomSelected(room_id, /*request_focus=*/false);
1843 }
1844 DrawRoomTab(room_id);
1845 }
1846 room_card->End();
1847
1848 if (!open) {
1851 }
1852
1853 room_cards_.erase(room_id);
1854 active_rooms_.erase(active_rooms_.Data + i);
1855 room_viewers_.Erase(room_id);
1856 ReleaseRoomPanelSlotId(room_id);
1857 i--;
1858 }
1859 }
1860}
1861
1863 if (auto it = room_panel_slot_ids_.find(room_id);
1864 it != room_panel_slot_ids_.end()) {
1865 return it->second;
1866 }
1867 const int slot_id = next_room_panel_slot_id_++;
1868 room_panel_slot_ids_[room_id] = slot_id;
1869 return slot_id;
1870}
1871
1873 room_panel_slot_ids_.erase(room_id);
1874}
1875
1877 const auto& theme = AgentUI::GetTheme();
1878 if (room_id < 0 || room_id >= 0x128) {
1879 ImGui::Text(tr("Invalid room ID: %d"), room_id);
1880 return;
1881 }
1882
1883 auto& room = rooms_[room_id];
1884
1885 if (!room.IsLoaded()) {
1886 auto status = room_loader_.LoadRoom(room_id, room);
1887 if (!status.ok()) {
1888 ImGui::TextColored(theme.text_error_red, tr("Failed to load room: %s"),
1889 status.message().data());
1890 return;
1891 }
1892
1894 auto sys_status = dungeon_editor_system_->ReloadRoom(room_id);
1895 if (!sys_status.ok()) {
1896 LOG_ERROR("DungeonEditorV2", "Failed to load system data: %s",
1897 sys_status.message().data());
1898 }
1899 }
1900 }
1901
1902 if (room.IsLoaded()) {
1903 bool needs_render = false;
1904
1905 if (room.blocks().empty()) {
1907 room.LoadRoomGraphics();
1908 needs_render = true;
1909 }
1910
1911 if (!room.AreObjectsLoaded()) {
1912 room.LoadObjects();
1913 needs_render = true;
1914 }
1915
1916 auto& bg1_bitmap = room.bg1_buffer().bitmap();
1917 if (needs_render || !bg1_bitmap.is_active() || bg1_bitmap.width() == 0) {
1918 room.RenderRoomGraphics();
1919 }
1920 }
1921
1922 if (room.IsLoaded()) {
1923 ImGui::TextColored(theme.text_success_green, ICON_MD_CHECK " Loaded");
1924 } else {
1925 ImGui::TextColored(theme.text_error_red, ICON_MD_PENDING " Not Loaded");
1926 }
1927 ImGui::SameLine();
1928 ImGui::TextDisabled(tr("Objects: %zu"), room.GetTileObjects().size());
1929
1930 if (core::FeatureFlags::get().dungeon.kUseWorkbench &&
1932 ImGui::SameLine();
1933 const std::string return_label = absl::StrFormat(
1934 ICON_MD_WORKSPACES " Workbench##return_workbench_%03X", room_id);
1935 if (ImGui::SmallButton(return_label.c_str())) {
1937 }
1938 if (ImGui::IsItemHovered()) {
1939 ImGui::SetTooltip(
1940 tr("Switch back to the integrated Dungeon Workbench workflow."));
1941 }
1942 }
1943
1944 // Warp to Room button — sends room ID to running Mesen2 emulator
1945 ImGui::SameLine();
1946 {
1948 bool connected = client && client->IsConnected();
1949 if (!connected) {
1950 ImGui::BeginDisabled();
1951 }
1952 std::string warp_label =
1953 absl::StrFormat(ICON_MD_ROCKET_LAUNCH " Warp##warp_%03X", room_id);
1954 if (ImGui::SmallButton(warp_label.c_str())) {
1955 auto status = client->WriteWord(0x7E00A0, static_cast<uint16_t>(room_id));
1956 if (status.ok()) {
1959 absl::StrFormat("Warped to room 0x%03X", room_id),
1961 }
1962 } else {
1965 absl::StrFormat("Warp failed: %s", status.message()),
1967 }
1968 }
1969 }
1970 if (!connected) {
1971 ImGui::EndDisabled();
1972 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1973 ImGui::SetTooltip(tr("Connect to Mesen2 first"));
1974 }
1975 } else {
1976 if (ImGui::IsItemHovered()) {
1977 ImGui::SetTooltip(tr("Warp to room 0x%03X in Mesen2"), room_id);
1978 }
1979 }
1980 }
1981
1982 ImGui::Separator();
1983
1984 // Use per-room viewer; reserve a bottom strip for the shared dungeon
1985 // status bar so this Windows-mode standalone window mirrors the Workbench
1986 // canvas pane's affordances.
1987 if (auto* viewer = GetStandaloneViewerForRoom(room_id)) {
1988 const float status_bar_reserved =
1989 std::max(ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f,
1991 ImGui::GetStyle().ItemSpacing.y;
1992 if (ImGui::BeginChild("##StandaloneRoomBody",
1993 ImVec2(0.0f, -status_bar_reserved), false)) {
1994 viewer->DrawDungeonCanvas(room_id);
1996 }
1997 ImGui::EndChild();
1998
1999 bool room_dirty = false;
2000 if (room_id >= 0 && room_id < static_cast<int>(rooms_.size())) {
2001 room_dirty = rooms_[room_id].HasUnsavedChanges();
2002 }
2003 const char* tool_mode =
2004 viewer->object_interaction().mode_manager().GetModeName();
2005 auto status = DungeonStatusBar::BuildState(*viewer, tool_mode, room_dirty);
2006 status.workflow_mode = workflow_mode_names::kStandalone;
2007 status.workflow_primary = false;
2008 status.can_undo = undo_manager_.CanUndo();
2009 status.can_redo = undo_manager_.CanRedo();
2010 status.undo_depth = static_cast<int>(undo_manager_.UndoStackSize());
2011 status.on_undo = [this]() {
2012 Undo().IgnoreError();
2013 };
2014 status.on_redo = [this]() {
2015 Redo().IgnoreError();
2016 };
2017 DungeonStatusBar::Draw(status);
2018 }
2019}
2020
2022 if (ImGui::GetCurrentContext() == nullptr) {
2024 return;
2025 }
2026 room_canvas_delete_shortcut_frame_ = ImGui::GetFrameCount();
2027}
2028
2030 if (!room_canvas_delete_shortcut_frame_.has_value()) {
2031 return;
2032 }
2033 if (ImGui::GetCurrentContext() == nullptr ||
2034 *room_canvas_delete_shortcut_frame_ != ImGui::GetFrameCount()) {
2036 }
2037}
2038
2040 DungeonCanvasViewer& viewer) {
2042 if (!room_canvas_delete_shortcut_frame_.has_value() ||
2043 ImGui::GetIO().WantTextInput || ImGui::IsAnyItemActive() ||
2044 !viewer.CanHandleRoomCanvasShortcut()) {
2045 return false;
2046 }
2047
2049 viewer.DeleteSelectedObjects();
2050 return true;
2051}
2052
2054 switch (intent) {
2056 OnRoomSelected(room_id, /*request_focus=*/true);
2057 break;
2059 // Explicitly pivot to panel workflow when user asks for standalone room.
2060 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
2061 return;
2062 }
2065 }
2066 // Update shared state (same as OnRoomSelected with request_focus=true)
2067 OnRoomSelected(room_id, /*request_focus=*/false);
2068 // Now force-open a standalone panel for this room
2069 ShowRoomPanel(room_id);
2070 break;
2071 }
2073 OnRoomSelected(room_id, /*request_focus=*/false);
2074 break;
2075 }
2076}
2077
2078void DungeonEditorV2::OnRoomSelected(int room_id, bool request_focus) {
2079 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
2080 LOG_WARN("DungeonEditorV2", "Ignoring invalid room selection: %d", room_id);
2081 return;
2082 }
2083 if (room_id != current_room_id_ && workbench_panel_) {
2085 }
2086 current_room_id_ = room_id;
2088 const auto context = ResolveDungeonRenderContextForRoom(room_id);
2089 if (context.uses_entrance()) {
2090 current_entrance_id_ = context.entrance_slot;
2092 }
2093 }
2094 room_selector_.set_current_room_id(static_cast<uint16_t>(room_id));
2095
2096 // Track recent rooms (remove if already present, add to front)
2097 recent_rooms_.erase(
2098 std::remove(recent_rooms_.begin(), recent_rooms_.end(), room_id),
2099 recent_rooms_.end());
2100 recent_rooms_.push_front(room_id);
2101 if (recent_rooms_.size() > kMaxRecentRooms) {
2102 recent_rooms_.pop_back();
2103 }
2104
2106 dungeon_editor_system_->SetExternalRoom(&rooms_[room_id]);
2107 }
2108
2109 // Update all sub-panels (Object Editor, Sprite Editor, etc.)
2110 SyncPanelsToRoom(room_id);
2111
2112 // Sync palette with current room (must happen before early return for focus changes)
2113
2114 // Sync palette with current room (must happen before early return for focus changes)
2115 if (room_id >= 0 && room_id < (int)rooms_.size()) {
2116 auto& room = rooms_[room_id];
2117 if (!room.IsLoaded()) {
2118 room_loader_.LoadRoom(room_id, room);
2119 }
2120
2121 if (room.IsLoaded()) {
2123 current_palette_id_ = room.ResolveDungeonPaletteId();
2126
2127 // Update viewer and object editor palette
2128 if (auto* viewer = GetViewerForRoom(room_id)) {
2129 ConfigureViewerRenderContext(viewer, room_id);
2130 viewer->SetCurrentPaletteId(current_palette_id_);
2131
2132 if (game_data()) {
2133 auto dungeon_main_pal_group =
2135 if (current_palette_id_ < (int)dungeon_main_pal_group.size()) {
2136 current_palette_ = dungeon_main_pal_group[current_palette_id_];
2140 viewer->SetCurrentPaletteGroup(current_palette_group_);
2144 }
2145 // Sync palette to graphics panel for proper sheet coloring
2149 }
2152 room_id, current_palette_group_);
2153 }
2154 }
2155 }
2156 }
2157 }
2158 }
2159
2160 // Workbench mode uses a single stable window and does not spawn per-room
2161 // panels. Keep selection + panels in sync and return.
2163 if (dependencies_.window_manager && request_focus) {
2164 // Only force-show if it's already visible or if it's the first initialization
2165 // This avoids obtrusive behavior when the user explicitly closed it.
2166 if (dependencies_.window_manager->IsWindowOpen("dungeon.workbench")) {
2167 dependencies_.window_manager->OpenWindow("dungeon.workbench");
2168 }
2169 }
2170 return;
2171 }
2172
2173 // Check if room is already open
2174 for (int i = 0; i < active_rooms_.Size; i++) {
2175 if (active_rooms_[i] == room_id) {
2176 // Always ensure panel is visible, even if already in active_rooms_
2178 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
2180 }
2181 if (request_focus) {
2182 FocusRoom(room_id);
2183 }
2184 return;
2185 }
2186 }
2187
2188 active_rooms_.push_back(room_id);
2190
2192 // Use unified ResourceLabelProvider for room names
2193 std::string room_name = absl::StrFormat(
2194 "[%03X] %s", room_id, zelda3::GetRoomLabel(room_id).c_str());
2195
2196 std::string base_card_id = absl::StrFormat("dungeon.room_%d", room_id);
2197
2199 {.card_id = base_card_id,
2200 .display_name = room_name,
2201 .window_title = ICON_MD_GRID_ON " " + room_name,
2202 .icon = ICON_MD_GRID_ON,
2203 .category = "Dungeon",
2204 .workflow_group = "Rooms",
2206 .shortcut_hint = "",
2207 .visibility_flag = nullptr,
2208 .priority = 200 + room_id});
2209
2211 }
2212}
2213
2215 if (entrance_id < 0 || entrance_id >= static_cast<int>(entrances_.size())) {
2216 return;
2217 }
2218 current_entrance_id_ = entrance_id;
2222
2223 const int room_id = ResolveEntranceRoomId(entrance_id);
2224 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
2225 return;
2226 }
2227 OnRoomSelected(room_id);
2228}
2229
2231 if (entrance_id < -1 || entrance_id >= static_cast<int>(entrances_.size()))
2232 return;
2233 automatic_entrance_preview_ = entrance_id == -1;
2235 current_entrance_id_ = entrance_id;
2237 OnRoomSelected(current_room_id_, /*request_focus=*/false);
2238}
2239
2240int DungeonEditorV2::ResolveEntranceRoomId(int entrance_id) const {
2241 if (entrance_id < 0 || entrance_id >= static_cast<int>(entrances_.size())) {
2242 return -1;
2243 }
2244 if (entrance_id < zelda3::kNumDungeonSpawnPoints) {
2245 const auto& spawn = spawn_points_[entrance_id];
2246 return spawn.spawn_id() == entrance_id ? spawn.room_id : -1;
2247 }
2248 return entrances_[entrance_id].room_;
2249}
2250
2252 int room_id) const {
2255 return context.effective_blockset();
2256}
2257
2259 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
2260 return;
2261 }
2262 auto& room = rooms_[room_id];
2263 if (!room.IsLoaded()) {
2264 return;
2265 }
2266 room.SetRenderEntranceBlockset(
2268}
2269
2271 int room_id) {
2272 if (viewer == nullptr) {
2273 return;
2274 }
2277 viewer->SetEntranceRenderContext(context.entrance_slot,
2278 context.effective_blockset());
2279 viewer->SetEntranceBlocksetResolver([this](int target_room) {
2280 return ResolveSelectedEntranceBlocksetForRoom(target_room);
2281 });
2282 const auto camera = GetEntranceCameraState(context.entrance_slot);
2283 if (camera.has_value() && camera->room_id == room_id) {
2284 viewer->SetEntranceCameraOverlay(context.entrance_slot, *camera);
2285 } else {
2287 }
2288}
2289
2290void DungeonEditorV2::add_room(int room_id) {
2291 OnRoomSelected(room_id);
2292}
2293
2295 auto it = room_cards_.find(room_id);
2296 if (it != room_cards_.end()) {
2297 it->second->Focus();
2298 }
2299}
2300
2306 OpenWindow("dungeon.workbench");
2307 } else {
2309 }
2310 }
2311}
2312
2314 if (enabled && dependencies_.window_manager) {
2316 OpenWindow("dungeon.workbench");
2317 if (workbench_panel_) {
2319 }
2320 } else {
2324 }
2327 }
2328 }
2329}
2330
2334
2337 LOG_ERROR("DungeonEditorV2", "Cannot place object: Invalid room ID %d",
2339 const std::string message = absl::StrFormat(
2340 "Cannot place 0x%02X: invalid room %d", obj.id_, current_room_id_);
2343 } else if (dependencies_.toast_manager) {
2345 }
2346 return;
2347 }
2348
2349 auto& room = rooms_[current_room_id_];
2350
2351 LOG_INFO("DungeonEditorV2",
2352 "Placing object ID=0x%02X at position (%d,%d) in room %03X", obj.id_,
2353 obj.x_, obj.y_, current_room_id_);
2354
2355 room.RenderRoomGraphics();
2356 LOG_DEBUG("DungeonEditorV2",
2357 "Object placed and room re-rendered successfully");
2358 // Brief success feedback so the user knows the placement was accepted.
2359 // Kept minimal (no inline panel update) — success is non-spammy by design.
2362 absl::StrFormat("Placed 0x%02X in room %03X", obj.id_,
2365 }
2366}
2367
2369 const std::function<void(int, zelda3::Room&, const std::string&, bool,
2370 int)>& visitor) {
2371 rooms_.ForEachLoaded([&](int id, zelda3::Room& room) {
2373 const auto previous_blocks = room.blocks();
2374 const auto previous_main = room.resolved_main_blockset();
2375 room.LoadRoomGraphics();
2376 if (room.blocks() != previous_blocks ||
2377 room.resolved_main_blockset() != previous_main) {
2378 // Membership resolution must not consume a group change without leaving
2379 // the room dirty for its eventual on-demand render.
2380 room.MarkGraphicsDirty();
2381 }
2383 visitor(id, room, DungeonRenderContextSourceName(context),
2384 id == current_room_id_, context.entrance_slot);
2385 });
2386}
2387
2389 int room_id, const zelda3::RoomObject& object) {
2390 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
2391 LOG_WARN("DungeonEditorV2", "Edit Graphics ignored (invalid room id %d)",
2392 room_id);
2393 return;
2394 }
2395
2396 auto* editor_manager = static_cast<EditorManager*>(dependencies_.custom_data);
2397 if (!editor_manager) {
2398 LOG_WARN("DungeonEditorV2",
2399 "Edit Graphics ignored (editor manager unavailable)");
2400 return;
2401 }
2402
2403 auto& room = rooms_[room_id];
2405 room.PrepareForRender();
2406 const auto object_stamp = GraphicsObjectStamp(object);
2407 std::optional<size_t> placed_index;
2408 bool ambiguous_placement = false;
2409 // The canvas callback supplies an object copy, without its room index.
2410 // Infer a placement only when its scalar identity has one match. An
2411 // unmatched copy is a catalog pattern; duplicates may be previewed, but
2412 // their mutation source is ambiguous until the callback carries an index.
2413 const auto& placed_objects = room.GetTileObjects();
2414 for (size_t i = 0; i < placed_objects.size(); ++i) {
2415 if (GraphicsObjectStamp(placed_objects[i]) != object_stamp)
2416 continue;
2417 if (placed_index) {
2418 ambiguous_placement = true;
2419 break;
2420 }
2421 placed_index = i;
2422 }
2424 if (!context.ok()) {
2425 LOG_WARN("DungeonEditorV2", "Edit Graphics: %s",
2426 std::string(context.status().message()).c_str());
2428 dependencies_.toast_manager->Show(std::string(context.status().message()),
2430 }
2431 return;
2432 }
2433 const auto entrance = ResolveDungeonRenderContextForRoom(room_id);
2434 context->label +=
2435 absl::StrFormat(" / %s", DungeonRenderContextSourceName(entrance));
2436 auto resolve_live_source =
2437 [this, room_id, object, object_stamp, placed_index, ambiguous_placement,
2438 entrance, owner = game_data_,
2439 source_rom = rom_](GraphicsEditContext& fresh) -> absl::Status {
2440 if (game_data_ != owner || rom_ != source_rom || !rom_ ||
2441 !rom_->is_loaded()) {
2442 return absl::FailedPreconditionError("Dungeon source session changed");
2443 }
2444 if (ambiguous_placement) {
2445 return absl::FailedPreconditionError(
2446 "Dungeon object placement is ambiguous; reopen Graphics");
2447 }
2448 auto* live_room = rooms_.GetIfMaterialized(room_id);
2449 if (!live_room || !live_room->IsLoaded()) {
2450 return absl::FailedPreconditionError("Dungeon source room changed");
2451 }
2452 if (!SameGraphicsEntrance(entrance,
2454 return absl::FailedPreconditionError(
2455 "Dungeon entrance or blockset source changed; reopen Graphics");
2456 }
2457 const zelda3::RoomObject* live_object = &object;
2458 if (placed_index) {
2459 const auto& objects = live_room->GetTileObjects();
2460 if (*placed_index >= objects.size() ||
2461 GraphicsObjectStamp(objects[*placed_index]) != object_stamp) {
2462 return absl::FailedPreconditionError(
2463 "Dungeon object placement changed; reopen Graphics");
2464 }
2465 live_object = &objects[*placed_index];
2466 }
2468 live_room->MarkGraphicsDirty();
2469 live_room->PrepareForRender();
2470 auto refreshed =
2471 MakeDungeonGraphicsEditContext(*live_room, *live_object, owner);
2472 if (!refreshed.ok())
2473 return refreshed.status();
2474 fresh = std::move(*refreshed);
2475 return absl::OkStatus();
2476 };
2477 context->resolve_live_source = resolve_live_source;
2478 // Display refresh retains its earlier captured-source behavior. The strict
2479 // live resolver runs only at the palette/pixel mutation boundary.
2480 context->refresh_palettes = [this, room_id, object, owner = game_data_](
2481 GraphicsEditContext& source) -> absl::Status {
2482 if (game_data_ != owner) {
2483 return absl::FailedPreconditionError("Dungeon source session changed");
2484 }
2485 auto& live_room = rooms_[room_id];
2487 live_room.MarkGraphicsDirty();
2488 live_room.PrepareForRender();
2489 auto refreshed = MakeDungeonGraphicsEditContext(live_room, object, owner);
2490 if (!refreshed.ok())
2491 return refreshed.status();
2492 return UpdateGraphicsContextPalettes(source, std::move(*refreshed));
2493 };
2494 context->render_preview =
2495 [this, room_id]() -> absl::StatusOr<usage_preview::PreviewImage> {
2496 auto& source = rooms_[room_id];
2498 // A stroke updates store pixels before committing its revision. Usage
2499 // Preview already throttles on pixel changes; render those live pixels too.
2500 source.MarkGraphicsDirty();
2501 source.PrepareForRender();
2503 layers.ApplyLayerMerging(source.layer_merging());
2504 layers.ApplyRoomEffect(source.effect());
2505 layers.ApplyGameLayerRegisters(source.GameLayerRegisters());
2506 gfx::Bitmap composite;
2507 source.RenderComposite(layers, composite);
2508 return usage_preview::SnapshotBitmap(composite);
2509 };
2510 context->return_to_source = [this, editor_manager, room_id]() {
2511 editor_manager->SwitchToEditor(EditorType::kDungeon, true);
2512 OnRoomSelected(room_id);
2513 };
2514
2515 if (auto* editor_set = editor_manager->GetCurrentEditorSet()) {
2516 if (auto* graphics = editor_set->GetGraphicsEditor()) {
2517 const auto loaded =
2518 editor_manager->EnsureEditorAssetsLoaded(EditorType::kGraphics);
2519 if (!loaded.ok()) {
2521 dependencies_.toast_manager->Show(std::string(loaded.message()),
2523 }
2524 return;
2525 }
2526 const auto status = graphics->OpenInContext(std::move(*context));
2527 if (!status.ok()) {
2528 LOG_WARN("DungeonEditorV2", "Edit Graphics: %s",
2529 std::string(status.message()).c_str());
2531 dependencies_.toast_manager->Show(std::string(status.message()),
2533 }
2534 return;
2535 }
2536 editor_manager->SwitchToEditor(EditorType::kGraphics, true);
2537 }
2538 }
2539}
2540
2542 int room_id, const zelda3::RoomObject& object) {
2543 if (object_tile_editor_panel_ == nullptr) {
2544 return absl::FailedPreconditionError(
2545 "Object Tile Editor panel is unavailable");
2546 }
2547 if (dependencies_.window_manager == nullptr) {
2548 return absl::FailedPreconditionError(
2549 "Workspace window manager is unavailable");
2550 }
2551 if (rom_ == nullptr || !rom_->is_loaded()) {
2552 return absl::FailedPreconditionError("ROM not loaded");
2553 }
2554 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
2555 return absl::OutOfRangeError("Dungeon room id is outside the room store");
2556 }
2558 return absl::UnimplementedError(
2559 "Tile editing is not yet supported for this object layout");
2560 }
2561 if (game_data_ == nullptr ||
2563 return absl::FailedPreconditionError("Dungeon palette data is unavailable");
2564 }
2565
2566 auto& room = rooms_[room_id];
2567 if (!room.IsLoaded()) {
2568 RETURN_IF_ERROR(room_loader_.LoadRoom(room_id, room));
2569 }
2571 room.PrepareForRender();
2572
2573 const int palette_id = room.ResolveDungeonPaletteId();
2574 const auto& dungeon_palettes = game_data_->palette_groups.dungeon_main;
2575 if (palette_id < 0 ||
2576 palette_id >= static_cast<int>(dungeon_palettes.size())) {
2577 return absl::OutOfRangeError(
2578 "Room resolves to an unavailable dungeon palette");
2579 }
2580 auto room_palette = dungeon_palettes[palette_id];
2581 const auto palette_group = zelda3::BuildDungeonRenderPaletteGroupFromGameData(
2582 room_palette, game_data_);
2583
2584 const absl::Status open_status = object_tile_editor_panel_->OpenForObject(
2585 object.id_, room_id, &rooms_, palette_group);
2586 if (!open_status.ok()) {
2587 // A modified hidden session rejects replacement to prevent data loss.
2588 // Surface that existing session so the user can apply, revert, or use the
2589 // explicit discard action described by the returned status.
2593 }
2594 return open_status;
2595 }
2599 return absl::NotFoundError(
2600 "Object Tile Editor window is not registered in this session");
2601 }
2602 return absl::OkStatus();
2603}
2604
2605void DungeonEditorV2::SwapRoomInPanel(int old_room_id, int new_room_id) {
2606 // Defer the swap until after the current frame's draw phase completes
2607 // This prevents modifying data structures while ImGui is still using them
2608 if (new_room_id < 0 || new_room_id >= static_cast<int>(rooms_.size())) {
2609 return;
2610 }
2611 pending_swap_.old_room_id = old_room_id;
2612 pending_swap_.new_room_id = new_room_id;
2613 pending_swap_.pending = true;
2614}
2615
2617 if (!pending_swap_.pending) {
2618 return;
2619 }
2620
2621 int old_room_id = pending_swap_.old_room_id;
2622 int new_room_id = pending_swap_.new_room_id;
2623 pending_swap_.pending = false;
2624
2625 // Find the position of old_room in active_rooms_
2626 int swap_index = -1;
2627 for (int i = 0; i < active_rooms_.Size; i++) {
2628 if (active_rooms_[i] == old_room_id) {
2629 swap_index = i;
2630 break;
2631 }
2632 }
2633
2634 if (swap_index < 0) {
2635 // Old room not found in active rooms, just select the new one
2636 OnRoomSelected(new_room_id);
2637 return;
2638 }
2639
2640 // Avoid swapping into an already-open room panel (the per-room maps assume
2641 // room IDs are unique). In this case, just focus/select the existing room.
2642 for (int i = 0; i < active_rooms_.Size; i++) {
2643 if (i != swap_index && active_rooms_[i] == new_room_id) {
2644 OnRoomSelected(new_room_id);
2645 return;
2646 }
2647 }
2648
2649 // Preserve the old panel's stable ImGui window identity by transferring its
2650 // slot id to the new room id.
2651 int slot_id = -1;
2652 if (auto it = room_panel_slot_ids_.find(old_room_id);
2653 it != room_panel_slot_ids_.end()) {
2654 slot_id = it->second;
2655 room_panel_slot_ids_.erase(it);
2656 } else {
2657 slot_id = next_room_panel_slot_id_++;
2658 }
2659 room_panel_slot_ids_[new_room_id] = slot_id;
2660
2661 // Preserve the viewer instance so canvas pan/zoom and UI state don't reset
2662 // when navigating with arrows (swap-in-panel).
2663 room_viewers_.Rename(old_room_id, new_room_id);
2664
2665 // Replace old room with new room in active_rooms_
2666 active_rooms_[swap_index] = new_room_id;
2668
2669 // Unregister old panel
2671 std::string old_card_id = absl::StrFormat("dungeon.room_%d", old_room_id);
2672 const bool old_pinned =
2675
2676 // Register new panel
2677 // Use unified ResourceLabelProvider for room names
2678 std::string new_room_name = absl::StrFormat(
2679 "[%03X] %s", new_room_id, zelda3::GetRoomLabel(new_room_id).c_str());
2680
2681 std::string new_card_id = absl::StrFormat("dungeon.room_%d", new_room_id);
2682
2684 {.card_id = new_card_id,
2685 .display_name = new_room_name,
2686 .window_title = ICON_MD_GRID_ON " " + new_room_name,
2687 .icon = ICON_MD_GRID_ON,
2688 .category = "Dungeon",
2689 .workflow_group = "Rooms",
2691 .shortcut_hint = "",
2692 .visibility_flag = nullptr,
2693 .priority = 200 + new_room_id});
2694
2695 if (old_pinned) {
2696 dependencies_.window_manager->SetWindowPinned(new_card_id, true);
2697 }
2699 }
2700
2701 // Clean up old room's card and viewer
2702 room_cards_.erase(old_room_id);
2703
2704 // Update current selection
2705 OnRoomSelected(new_room_id, /*request_focus=*/false);
2706}
2707
2710 return;
2711 }
2712
2713 const bool enabled = pending_workflow_mode_.enabled;
2714 const bool show_toast = pending_workflow_mode_.show_toast;
2716 SetWorkbenchWorkflowMode(enabled, show_toast);
2717}
2718
2721 return;
2722 }
2723
2727
2728 auto* window_manager = dependencies_.window_manager;
2729 if (!window_manager) {
2730 return;
2731 }
2732
2733 if (!window_manager->OpenWindow(request.session_id, request.window_id)) {
2734 return;
2735 }
2736
2737 if (ImGui::GetCurrentContext() != nullptr) {
2738 const std::string window_name = window_manager->GetWorkspaceWindowName(
2739 request.session_id, request.window_id);
2740 if (!window_name.empty()) {
2741 ImGui::SetWindowFocus(window_name.c_str());
2742 }
2743 }
2744}
2745
2747 room_viewers_.Touch(room_id);
2748}
2749
2751 // No-op: LruCache handles removal internally via Erase()
2752 (void)room_id;
2753}
2754
2756 if (!viewer) {
2757 return;
2758 }
2759
2760 viewer->SetShowObjectPanelCallback([this]() {
2763 OpenWindow("dungeon.workbench");
2764 return;
2765 }
2767 });
2768 viewer->SetShowSpritePanelCallback([this]() {
2771 OpenWindow("dungeon.workbench");
2772 return;
2773 }
2774 OpenWindow("dungeon.sprite_editor");
2775 });
2776 viewer->SetShowItemPanelCallback([this]() {
2779 OpenWindow("dungeon.workbench");
2780 return;
2781 }
2782 OpenWindow("dungeon.item_editor");
2783 });
2784 viewer->SetShowRoomListCallback([this]() {
2787 OpenWindow("dungeon.workbench");
2788 return;
2789 }
2791 });
2792 viewer->SetShowRoomMatrixCallback([this]() {
2795 OpenWindow("dungeon.workbench");
2796 return;
2797 }
2799 });
2800 viewer->SetShowEntranceListCallback([this]() {
2803 OpenWindow("dungeon.workbench");
2804 return;
2805 }
2806 OpenWindow("dungeon.entrance_properties");
2807 });
2808 viewer->SetShowRoomGraphicsCallback([this]() {
2811 OpenWindow("dungeon.workbench");
2812 return;
2813 }
2815 });
2817 [this](int room_id, const zelda3::RoomObject& object) {
2818 FocusObjectCoverage(room_id, object);
2819 });
2820 viewer->SetShowDoorEditorCallback([this]() {
2823 OpenWindow("dungeon.workbench");
2824 return;
2825 }
2826 OpenWindow("dungeon.door_editor");
2827 });
2828 viewer->SetShowDungeonSettingsCallback([this]() {
2829 if (workbench_panel_) {
2831 }
2832 OpenWindow("dungeon.workbench");
2833 });
2835 [this](int target_room_id, const zelda3::RoomObject& object) {
2836 OpenGraphicsEditorForObject(target_room_id, object);
2837 });
2839 [this](int target_room_id, const zelda3::RoomObject& object) {
2840 const absl::Status status =
2841 OpenObjectTileEditorForObject(target_room_id, object);
2842 if (status.ok()) {
2843 return;
2844 }
2845 LOG_ERROR("DungeonEditorV2", "Edit Object Tiles failed: %s",
2846 status.message().data());
2849 absl::StrFormat("Edit Object Tiles failed: %s", status.message()),
2851 }
2852 });
2853 viewer->SetSaveRoomCallback([this](int target_room_id) {
2854 auto status = SaveRoom(target_room_id);
2855 if (!status.ok()) {
2856 LOG_ERROR("DungeonEditorV2", "Apply Room failed: %s",
2857 status.message().data());
2860 absl::StrFormat("Apply Room failed: %s", status.message()),
2862 }
2863 return;
2864 }
2866 dependencies_.toast_manager->Show("Applied room to ROM buffer",
2868 }
2869 });
2870}
2871
2874 (void)room_id;
2875 return GetWorkbenchViewer();
2876 }
2877
2878 return GetStandaloneViewerForRoom(room_id);
2879}
2880
2882 // Set eviction predicate to protect active rooms from eviction
2883 room_viewers_.SetEvictionPredicate([this](const int& candidate) {
2884 for (int i = 0; i < active_rooms_.size(); ++i) {
2885 if (active_rooms_[i] == candidate)
2886 return false;
2887 }
2888 return true;
2889 });
2890
2891 if (auto* existing = room_viewers_.Get(room_id)) {
2892 // Viewer already exists - Get() already touched LRU
2893 auto* viewer_ptr = existing->get();
2894 RefreshViewerRuntimeContext(viewer_ptr, room_id);
2896
2897 // Update pinned state from manager
2899 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
2900 viewer_ptr->SetPinned(
2902 viewer_ptr->SetPinCallback([this, card_id, room_id](bool pinned) {
2904 dependencies_.window_manager->SetWindowPinned(card_id, pinned);
2905 if (auto* existing_viewer = room_viewers_.Get(room_id)) {
2906 (*existing_viewer)->SetPinned(pinned);
2907 }
2908 }
2909 });
2910 }
2911
2912 return viewer_ptr;
2913 }
2914
2915 // Creating a new viewer - Insert will handle LRU and eviction
2916 {
2917 auto viewer = std::make_unique<DungeonCanvasViewer>(rom_);
2918 viewer->SetCompactHeaderMode(false);
2919 viewer->SetRoomDetailsExpanded(true);
2920 DungeonCanvasViewer* viewer_ptr = viewer.get();
2921 ConfigureEditableViewer(viewer_ptr);
2922 viewer->SetRoomNavigationCallback([this](int target_room) {
2923 if (target_room >= 0 && target_room < static_cast<int>(rooms_.size())) {
2924 OnRoomSelected(target_room);
2925 }
2926 });
2927 // Swap callback swaps the room in the current panel instead of opening new
2928 viewer->SetRoomSwapCallback([this](int old_room, int new_room) {
2929 SwapRoomInPanel(old_room, new_room);
2930 });
2931 // Wire up pinning for room panels.
2932 if (dependencies_.window_manager) {
2933 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
2934 viewer->SetPinned(dependencies_.window_manager->IsWindowPinned(card_id));
2935 viewer->SetPinCallback([this, card_id, room_id](bool pinned) {
2936 if (dependencies_.window_manager) {
2937 dependencies_.window_manager->SetWindowPinned(card_id, pinned);
2938 // Sync state back to viewer in all panels showing this room
2939 if (auto* existing_viewer = room_viewers_.Get(room_id)) {
2940 (*existing_viewer)->SetPinned(pinned);
2941 }
2942 }
2943 });
2944 }
2945
2946 auto* stored = room_viewers_.Insert(room_id, std::move(viewer));
2947 RefreshViewerRuntimeContext(stored->get(), room_id);
2948 ApplyEntranceRenderContext(room_id);
2949 return stored->get();
2950 }
2951}
2952
2953void DungeonEditorV2::ConfigureEditableViewer(DungeonCanvasViewer* viewer) {
2954 ConfigureViewerUndoHooks(viewer);
2956 [this](const zelda3::RoomObject& obj) { HandleObjectPlaced(obj); });
2957 WireViewerPanelCallbacks(viewer);
2958}
2959
2960void DungeonEditorV2::RefreshViewerRuntimeContext(DungeonCanvasViewer* viewer,
2961 int room_id,
2962 bool rebind_project) {
2963 if (!viewer) {
2964 return;
2965 }
2966
2967 if (viewer->rom() != rom_) {
2968 viewer->SetRom(rom_);
2969 }
2970 if (viewer->rooms() != &rooms_) {
2971 viewer->SetRooms(&rooms_);
2972 }
2973 if (viewer->game_data() != game_data_) {
2974 viewer->SetGameData(game_data_);
2975 }
2976 viewer->SetRenderer(renderer_);
2977 viewer->SetCurrentPaletteGroup(current_palette_group_);
2978 viewer->SetCurrentPaletteId(current_palette_id_);
2979 ConfigureViewerRenderContext(viewer, room_id);
2980 viewer->SetEditorSystem(dungeon_editor_system_.get());
2981 viewer->SetMinecartTrackPanel(minecart_track_editor_panel_);
2982 if (rebind_project || viewer->project() != dependencies_.project) {
2983 viewer->SetProject(dependencies_.project);
2984 }
2985}
2986
2987DungeonCanvasViewer* DungeonEditorV2::GetWorkbenchViewer() {
2988 if (!workbench_viewer_) {
2989 workbench_viewer_ = std::make_unique<DungeonCanvasViewer>(rom_);
2990 auto* viewer = workbench_viewer_.get();
2991 viewer->SetCompactHeaderMode(true);
2992 viewer->SetRoomDetailsExpanded(false);
2993 viewer->SetHeaderVisible(false);
2994 viewer->SetHeaderHiddenMetadataHudVisible(false);
2995 ConfigureEditableViewer(viewer);
2996
2997 // In workbench mode, arrow navigation swaps the current room without
2998 // changing window identities.
2999 viewer->SetRoomSwapCallback([this](int /*old_room*/, int new_room) {
3000 OnRoomSelected(new_room, /*request_focus=*/false);
3001 });
3002 viewer->SetRoomNavigationCallback([this](int target_room) {
3003 OnRoomSelected(target_room, /*request_focus=*/false);
3004 });
3005 }
3006
3007 auto* viewer = workbench_viewer_.get();
3008 RefreshViewerRuntimeContext(viewer, current_room_id_);
3009 return viewer;
3010}
3011
3012DungeonCanvasViewer* DungeonEditorV2::GetWorkbenchCompareViewer(int room_id) {
3013 if (!workbench_compare_viewer_) {
3014 workbench_compare_viewer_ = std::make_unique<DungeonCanvasViewer>(rom_);
3015 auto* viewer = workbench_compare_viewer_.get();
3016 viewer->SetCompactHeaderMode(true);
3017 viewer->SetRoomDetailsExpanded(false);
3018 viewer->SetHeaderHiddenMetadataHudVisible(false);
3019 // Compare viewer is read-only by default: no object selection/mutation, but
3020 // still allows canvas pan/zoom.
3021 viewer->SetObjectInteractionEnabled(false);
3022 viewer->SetHeaderReadOnly(true);
3023 viewer->SetHeaderVisible(false);
3024 }
3025
3026 auto* viewer = workbench_compare_viewer_.get();
3027 RefreshViewerRuntimeContext(viewer, room_id);
3028 viewer->SetObjectInteractionEnabled(false);
3029 viewer->SetHeaderReadOnly(true);
3030 viewer->SetHeaderVisible(false);
3031 return viewer;
3032}
3033
3034void DungeonEditorV2::SyncPanelsToRoom(int room_id) {
3035 // Update object editor card with current viewer
3036 if (object_selector_panel_) {
3037 object_selector_panel_->SetCanvasViewerProvider(
3038 [this]() { return GetViewerForRoom(current_room_id_); });
3039 object_selector_panel_->SetCurrentRoom(room_id);
3040 object_selector_panel_->SetCanvasViewer(GetViewerForRoom(room_id));
3041 }
3042 if (door_editor_panel_) {
3043 door_editor_panel_->SetCanvasViewerProvider(
3044 [this]() { return GetViewerForRoom(current_room_id_); });
3045 door_editor_panel_->SetCurrentRoom(room_id);
3046 door_editor_panel_->SetCanvasViewer(GetViewerForRoom(room_id));
3047 }
3048
3049 // Update sprite and item editor panels with current viewer
3050 if (sprite_editor_panel_) {
3051 sprite_editor_panel_->SetCanvasViewer(GetViewerForRoom(room_id));
3052 }
3053 if (item_editor_panel_) {
3054 item_editor_panel_->SetCanvasViewer(GetViewerForRoom(room_id));
3055 }
3056 if (custom_collision_panel_) {
3057 auto* viewer = GetViewerForRoom(room_id);
3058 custom_collision_panel_->SetCanvasViewer(viewer);
3059 if (viewer) {
3060 custom_collision_panel_->SetInteraction(&viewer->object_interaction());
3061 }
3062 }
3063 if (water_fill_panel_) {
3064 auto* viewer = GetViewerForRoom(room_id);
3065 water_fill_panel_->SetCanvasViewer(viewer);
3066 if (viewer) {
3067 water_fill_panel_->SetInteraction(&viewer->object_interaction());
3068 }
3069 }
3070
3071 if (room_tag_editor_panel_) {
3072 room_tag_editor_panel_->SetCurrentRoomId(room_id);
3073 }
3074
3075 if (overlay_manager_panel_) {
3076 auto* viewer = GetViewerForRoom(room_id);
3077 if (viewer) {
3079 overlay_state.show_grid = viewer->mutable_show_grid();
3080 overlay_state.show_object_bounds = viewer->mutable_show_object_bounds();
3081 overlay_state.show_coordinate_overlay =
3082 viewer->mutable_show_coordinate_overlay();
3083 overlay_state.show_room_debug_info =
3084 viewer->mutable_show_room_debug_info();
3085 overlay_state.show_texture_debug = viewer->mutable_show_texture_debug();
3086 overlay_state.show_layer_info = viewer->mutable_show_layer_info();
3087 overlay_state.show_minecart_tracks =
3088 viewer->mutable_show_minecart_tracks();
3089 overlay_state.show_custom_collision =
3090 viewer->mutable_show_custom_collision_overlay();
3091 overlay_state.show_track_collision =
3092 viewer->mutable_show_track_collision_overlay();
3093 overlay_state.show_camera_quadrants =
3094 viewer->mutable_show_camera_quadrant_overlay();
3095 overlay_state.show_minecart_sprites =
3096 viewer->mutable_show_minecart_sprite_overlay();
3097 overlay_state.show_collision_legend =
3098 viewer->mutable_show_track_collision_legend();
3099 overlay_manager_panel_->SetState(overlay_state);
3100 }
3101 }
3102}
3103
3104void DungeonEditorV2::ShowRoomPanel(int room_id) {
3105 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
3106 return;
3107 }
3108
3109 bool already_active = false;
3110 for (int i = 0; i < active_rooms_.Size; ++i) {
3111 if (active_rooms_[i] == room_id) {
3112 already_active = true;
3113 break;
3114 }
3115 }
3116 if (!already_active) {
3117 active_rooms_.push_back(room_id);
3118 room_selector_.set_active_rooms(active_rooms_);
3119 }
3120
3121 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
3122
3123 if (dependencies_.window_manager) {
3124 if (!dependencies_.window_manager->GetWindowDescriptor(
3125 dependencies_.window_manager->GetActiveSessionId(), card_id)) {
3126 std::string room_name = absl::StrFormat(
3127 "[%03X] %s", room_id, zelda3::GetRoomLabel(room_id).c_str());
3128 dependencies_.window_manager->RegisterWindow(
3129 {.card_id = card_id,
3130 .display_name = room_name,
3131 .window_title = ICON_MD_GRID_ON " " + room_name,
3132 .icon = ICON_MD_GRID_ON,
3133 .category = "Dungeon",
3134 .workflow_group = "Rooms",
3135 .presentation = WindowPresentationPolicy::OptionalPopOut(),
3136 .shortcut_hint = "",
3137 .visibility_flag = nullptr,
3138 .priority = 200 + room_id});
3139 }
3140 dependencies_.window_manager->OpenWindow(card_id);
3141 }
3142
3143 // Create or update the PanelWindow for this room
3144 if (room_cards_.find(room_id) == room_cards_.end()) {
3145 std::string base_name = absl::StrFormat(
3146 "[%03X] %s", room_id, zelda3::GetRoomLabel(room_id).c_str());
3147 const int slot_id = GetOrCreateRoomPanelSlotId(room_id);
3148 std::string card_name_str = absl::StrFormat(
3149 "%s###RoomPanelSlot%d", MakePanelTitle(base_name).c_str(), slot_id);
3150
3151 auto card = std::make_shared<gui::PanelWindow>(card_name_str.c_str(),
3153 card->SetDefaultSize(620, 700);
3154 room_cards_[room_id] = card;
3155 }
3156}
3157
3158} // namespace yaze::editor
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
bool is_loaded() const
Definition rom.h:187
static Flags & get()
Definition features.h:119
Loads and queries the hack manifest JSON for yaze-ASM integration.
const MinecartTrackLayout & minecart_track_layout() const
bool loaded() const
Check if the manifest has been loaded.
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void SetInteraction(DungeonObjectInteraction *interaction)
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void SetCheckObjectCoverageCallback(std::function< void(int, const zelda3::RoomObject &)> callback)
void SetShowEntranceListCallback(std::function< void()> callback)
void SetEditorSystem(zelda3::DungeonEditorSystem *system)
void SetMinecartTrackPanel(MinecartTrackEditorPanel *panel)
void SetShowItemPanelCallback(std::function< void()> callback)
void SetShowRoomGraphicsCallback(std::function< void()> callback)
void SetProject(const project::YazeProject *project)
void SetRenderer(gfx::IRenderer *renderer)
void SetEditGraphicsCallback(std::function< void(int, const zelda3::RoomObject &)> callback)
void SetShowRoomListCallback(std::function< void()> callback)
void SetEntranceBlocksetResolver(std::function< uint8_t(int)> resolver)
void RefreshRomBackedState(Rom *rom, zelda3::GameData *game_data, DungeonRoomStore *rooms, int room_id)
DungeonObjectInteraction & object_interaction()
void SetShowRoomMatrixCallback(std::function< void()> callback)
void SetShowSpritePanelCallback(std::function< void()> callback)
void SetShowObjectPanelCallback(std::function< void()> callback)
void SetEntranceRenderContext(int entrance_id, uint8_t entrance_blockset)
const project::YazeProject * project() const
void SetEditObjectTilesCallback(std::function< void(int, const zelda3::RoomObject &)> callback)
void SetSaveRoomCallback(std::function< void(int)> callback)
void SetCurrentPaletteGroup(const gfx::PaletteGroup &group, bool force_refresh=false)
void SetShowDungeonSettingsCallback(std::function< void()> callback)
void SetEntranceCameraOverlay(int entrance_slot, const DungeonEntranceCameraState &state)
void SetRooms(DungeonRoomStore *rooms)
void SetGameData(zelda3::GameData *game_data)
void SetShowDoorEditorCallback(std::function< void()> callback)
void SetPinCallback(std::function< void(bool)> callback)
class MinecartTrackEditorPanel * minecart_track_editor_panel_
DungeonRenderContext ResolveDungeonRenderContextForRoom(int room_id) const
absl::Status EditRoomGraphicsSheet(int room_id, int slot, int sheet_id)
std::unique_ptr< CustomCollisionPanel > owned_custom_collision_panel_
class CustomCollisionPanel * custom_collision_panel_
std::optional< int > room_canvas_delete_shortcut_frame_
void RefreshViewerRuntimeContext(DungeonCanvasViewer *viewer, int room_id, bool rebind_project=false)
void ToggleWorkbenchWorkflowMode(bool show_toast=true)
void OpenGraphicsEditorForObject(int room_id, const zelda3::RoomObject &object)
absl::Status OpenObjectTileEditorForObject(int room_id, const zelda3::RoomObject &object)
class ItemEditorPanel * item_editor_panel_
std::unique_ptr< RoomTagEditorPanel > owned_room_tag_editor_panel_
class ObjectCoveragePanel * object_coverage_panel_
std::unique_ptr< ObjectCoveragePanel > CreateObjectCoveragePanel()
absl::StatusOr< zelda3::RoomsetUsageReport > GetRoomGraphicsUsage(int roomset) const
void SetDependencies(const EditorDependencies &deps) override
std::unique_ptr< ObjectCoveragePanel > owned_object_coverage_panel_
util::LruCache< int, std::unique_ptr< DungeonCanvasViewer > > room_viewers_
gfx::PaletteGroup current_palette_group_
void OnEntranceSelected(int entrance_id)
static constexpr const char * kEntranceListId
PendingStandaloneToolWindow pending_standalone_tool_window_
class SpriteEditorPanel * sprite_editor_panel_
void HandleObjectPlaced(const zelda3::RoomObject &obj)
std::unique_ptr< zelda3::DungeonEditorSystem > dungeon_editor_system_
std::array< zelda3::RoomEntrance, zelda3::kNumDungeonEntranceSlots > entrances_
void OpenWindow(const std::string &window_id)
DoorEditorContent * door_editor_panel_
std::unordered_map< int, int > room_panel_slot_ids_
class DungeonWorkbenchContent * workbench_panel_
int ResolveEntranceRoomId(int entrance_id) const
void OnRoomSelected(int room_id, bool request_focus=true)
absl::Status ApplyMinecartCollisionStamp(int room_id, const zelda3::CustomCollisionMap &expected, const MinecartCollisionStamp &stamp)
std::optional< DungeonEntranceCameraState > GetEntranceCameraState(int slot_index) const
PendingCollisionUndo pending_collision_undo_
void FocusObjectCoverage(int room_id, const zelda3::RoomObject &object)
void ConfigureEntrancePropertyEditCallbacks(DungeonEntrancesPanel *panel)
ObjectTileEditorPanel * object_tile_editor_panel_
ObjectSelectorContent * object_selector_panel_
OverlayManagerPanel * overlay_manager_panel_
uint8_t ResolveSelectedEntranceBlocksetForRoom(int room_id) const
DungeonCanvasViewer * GetWorkbenchCompareViewer(int room_id)
bool CycleObjectSelection(int direction)
void WireViewerPanelCallbacks(DungeonCanvasViewer *viewer)
absl::Status EditRoomMetadata(int room_id, const RoomMetadataEdit &edit)
std::unique_ptr< DungeonCanvasViewer > workbench_compare_viewer_
PendingWaterFillUndo pending_water_fill_undo_
RoomMatrixContent * room_matrix_panel_
std::unique_ptr< MinecartTrackEditorPanel > owned_minecart_track_editor_panel_
absl::Status ApplyMinecartTrackEdit(const MinecartTrack &before, const MinecartTrack &after, bool move_cart)
void SetGraphicsPreviewEntrance(int entrance_id)
std::array< PendingEntityUndo, 3 > pending_entity_undo_
gui::PaletteEditorWidget palette_editor_
std::unique_ptr< DoorEditorContent > owned_door_editor_panel_
bool ConsumeRoomCanvasDeleteShortcut(DungeonCanvasViewer &viewer)
static bool IsValidRoomId(int room_id)
void VisitGraphicsUsageRooms(const std::function< void(int, zelda3::Room &, const std::string &, bool, int)> &visitor)
void SwapRoomInPanel(int old_room_id, int new_room_id)
RoomGraphicsContent * room_graphics_panel_
static constexpr size_t kMaxRecentRooms
std::array< zelda3::DungeonSpawnPoint, zelda3::kNumDungeonSpawnPoints > spawn_points_
void SetWorkbenchWorkflowMode(bool enabled, bool show_toast=true)
class RoomTagEditorPanel * room_tag_editor_panel_
std::optional< SaveTransactionSnapshot > save_transaction_snapshot_
std::unique_ptr< ObjectSelectorContent > owned_object_selector_panel_
static constexpr const char * kDoorEditorId
DungeonCanvasViewer * GetViewerForRoom(int room_id)
PendingSelectionUndo pending_selection_undo_
class WaterFillPanel * water_fill_panel_
bool NavigateToAdjacentRoom(DungeonRoomNavigationDirection direction)
DungeonCanvasViewer * GetWorkbenchViewer()
std::unordered_map< int, std::shared_ptr< gui::PanelWindow > > room_cards_
void ApplyEntranceRenderContext(int room_id)
static constexpr const char * kObjectSelectorId
void ConfigureViewerRenderContext(DungeonCanvasViewer *viewer, int room_id)
static constexpr const char * kRoomGraphicsId
void QueueWorkbenchWorkflowMode(bool enabled, bool show_toast=true)
static constexpr const char * kRoomMatrixId
void HandleDungeonPaletteChanged(gui::DungeonPaletteChange change)
static constexpr const char * kRoomSelectorId
PaletteEditorContent * palette_editor_panel_
DungeonSelectionClipboard selection_clipboard_
std::unique_ptr< DungeonCanvasViewer > workbench_viewer_
DungeonCanvasViewer * GetStandaloneViewerForRoom(int room_id)
std::unique_ptr< WaterFillPanel > owned_water_fill_panel_
absl::Status ApplyMinecartCollisionBatch(const std::vector< zelda3::TrackCollisionResult > &preview, const zelda3::GeneratorOptions &options)
DungeonEntrancesPanel * entrance_navigator_panel_
static constexpr const char * kPaletteEditorId
PendingWorkflowMode pending_workflow_mode_
void InvalidateDungeonPaletteUsers(gui::DungeonPaletteChange change)
void HandleArenaPaletteChanged(const std::string &group_name, int palette_index)
std::vector< std::string > workbench_suspended_navigation_window_ids_
void SetObjectPlacedCallback(std::function< void(const zelda3::RoomObject &)> callback)
void SetOpenMinecartEditorWindowCallback(std::function< bool()> callback)
void SetOpenTileEditorWindowCallback(std::function< bool()> callback)
void SetTileEditorPanel(ObjectTileEditorPanel *panel)
void SetEditObjectTilesCallback(std::function< absl::Status(int, const zelda3::RoomObject &)> callback)
absl::Status LoadRoom(int room_id, zelda3::Room &room)
absl::Status LoadRoomEntrances(std::array< zelda3::RoomEntrance, zelda3::kNumDungeonEntranceSlots > &entrances)
absl::Status LoadDungeonSpawnPoints(std::array< zelda3::DungeonSpawnPoint, zelda3::kNumDungeonSpawnPoints > &spawn_points)
void SetGameData(zelda3::GameData *game_data)
void SetRoomSelectedWithIntentCallback(std::function< void(int, RoomSelectionIntent)> callback)
void SetEntranceSelectedCallback(std::function< void(int)> callback)
void set_entrances(std::array< zelda3::RoomEntrance, zelda3::kNumDungeonEntranceSlots > *entrances)
void set_current_entrance_id(int entrance_id)
void set_rooms(DungeonRoomStore *rooms)
void SetRoomSelectedCallback(std::function< void(int)> callback)
void set_spawn_points(std::array< zelda3::DungeonSpawnPoint, zelda3::kNumDungeonSpawnPoints > *spawn_points)
void SetProject(const project::YazeProject *project)
void set_active_rooms(const ImVector< int > &rooms)
zelda3::Room * GetIfMaterialized(int room_id)
zelda3::Room * GetIfLoaded(int room_id)
void SetGameData(zelda3::GameData *game_data)
static void Draw(const DungeonStatusBarState &state)
static DungeonStatusBarState BuildState(const DungeonCanvasViewer &viewer, const char *tool_mode, bool room_dirty)
void SetOpenKeyboardShortcutsCallback(std::function< void()> callback)
void SetUndoRedoProvider(std::function< bool()> can_undo, std::function< bool()> can_redo, std::function< void()> on_undo, std::function< void()> on_redo, std::function< std::string()> undo_desc, std::function< std::string()> redo_desc, std::function< int()> undo_depth)
void SetPrimaryCanvasDrawnCallback(std::function< void(DungeonCanvasViewer &)> cb)
void SetRoomRenderContextCallback(std::function< void(int)> callback)
void SetObjectCoverageContent(WindowContent *object_coverage)
void SetOnInspectorSideChanged(std::function< void(bool)> cb)
void SetStandaloneToolCallbacks(std::function< bool(const std::string &)> is_window_open, std::function< bool(const std::string &)> open_and_focus_window)
void SetEmbeddedEditorPanels(WindowContent *object_selector, WindowContent *door_editor, WindowContent *sprite_editor, WindowContent *item_editor, WindowContent *room_graphics, WindowContent *palette_editor)
void SetEmbeddedToolPanels(RoomTagEditorPanel *room_tags, CustomCollisionPanel *custom_collision, WaterFillPanel *water_fill, MinecartTrackEditorPanel *minecart_tracks)
void NotifyRoomChanged(int previous_room_id)
Called by the editor when the current room changes.
void SetPitDamageTableProvider(std::function< zelda3::PitDamageTable *()> provider)
void SetNavigationPanels(RoomMatrixContent *room_matrix, DungeonEntrancesPanel *entrances)
The EditorManager controls the main editor window and manages the various editor classes.
EditorContext context() const
Definition editor.h:364
UndoManager undo_manager_
Definition editor.h:371
virtual void SetDependencies(const EditorDependencies &deps)
Definition editor.h:254
zelda3::GameData * game_data() const
Definition editor.h:357
EditorDependencies dependencies_
Definition editor.h:370
EditorType type_
Definition editor.h:369
void SetCanvasViewer(DungeonCanvasViewer *viewer)
std::string GetId() const override
Unique identifier for this panel.
void SetProjectChangedCallback(ProjectChangedCallback callback)
void SetRailPaletteCallback(std::function< bool()> draw)
absl::Status RebindProjectContext(project::YazeProject *project)
void SetTrackEditCallback(TrackEditCallback callback)
void SetCollisionBatchApplyCallback(CollisionBatchApplyCallback callback)
void SetProjectDraftChangedCallback(ProjectDraftChangedCallback callback)
void SetProjectSaveCallback(ProjectSaveCallback callback)
void SetCollisionStampCallback(MinecartCollisionApply apply)
void SetRooms(DungeonRoomStore *rooms)
void SetProject(project::YazeProject *project)
void SetPlacementError(const std::string &message)
void SetRooms(DungeonRoomStore *rooms)
void SetGameData(zelda3::GameData *game_data)
void SetCurrentPaletteGroup(const gfx::PaletteGroup &group)
void SetStandardWritePreflightCallback(StandardWritePreflightCallback callback)
void SetCurrentPaletteGroup(const gfx::PaletteGroup &group)
void SetCurrentPaletteGroupForRoom(int room_id, const gfx::PaletteGroup &group)
void SetTilesAppliedCallback(TilesAppliedCallback callback)
absl::Status OpenForObject(int16_t object_id, int room_id, DungeonRoomStore *rooms)
std::string GetId() const override
Unique identifier for this panel.
void SetCurrentPaletteGroup(const gfx::PaletteGroup &group)
Set the current palette group for graphics rendering.
void SetRenderContextCallback(std::function< void(int)> callback)
void SetRooms(DungeonRoomStore *rooms)
std::string GetDisplayString(const std::string &name) const
Live, platform-formatted binding for a shortcut name.
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void Show(const std::string &message, ToastType type=ToastType::kInfo, float ttl_seconds=3.0f, std::string action_label={}, std::function< void()> on_action=nullptr)
std::string GetRedoDescription() const
Description of the action that would be redone (for UI)
std::string GetUndoDescription() const
Description of the action that would be undone (for UI)
size_t UndoStackSize() const
std::string GetDungeonInspectorSide() const
void SetDungeonInspectorSide(std::string side)
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void SetInteraction(DungeonObjectInteraction *interaction)
std::string GetWorkspaceWindowName(size_t session_id, const std::string &base_window_id) const
Resolve the exact ImGui window name for a panel by base ID.
void SetWindowPinned(size_t session_id, const std::string &base_window_id, bool pinned)
void RegisterWindowContent(std::unique_ptr< WindowContent > window)
Register a WindowContent instance for central drawing.
void RegisterWindow(size_t session_id, const WindowDescriptor &descriptor)
bool IsWindowOpen(size_t session_id, const std::string &base_window_id) const
bool OpenWindow(size_t session_id, const std::string &base_window_id)
bool IsWindowPinned(size_t session_id, const std::string &base_window_id) const
void UnregisterWindow(size_t session_id, const std::string &base_window_id)
static std::shared_ptr< MesenSocketClient > & GetClient()
int RegisterPaletteListener(PaletteChangeCallback callback)
Register a callback for palette change notifications.
Definition arena.cc:580
void ProcessTextureQueue(IRenderer *renderer)
Definition arena.cc:211
void UnregisterPaletteListener(int listener_id)
Unregister a palette change listener.
Definition arena.cc:587
static Arena & Get()
Definition arena.cc:24
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:70
void Initialize(zelda3::GameData *game_data)
Initialize the palette manager with GameData.
static PaletteManager & Get()
Get the singleton instance.
void SetOnDungeonPaletteChanged(std::function< void(DungeonPaletteChange)> callback)
void SetDungeonRenderPaletteMode(bool enabled)
void Initialize(zelda3::GameData *game_data)
Draggable, dockable panel for editor sub-windows.
bool Begin(bool *p_open=nullptr)
void SetDefaultSize(float width, float height)
static CustomObjectManager & Get()
static DrawRoutineRegistry & Get()
static ObjectDimensionTable & Get()
static bool IsEditableStandardObject(int16_t object_id)
Dungeon Room Entrance or Spawn Point.
RoomLayerManager - Manages layer visibility and compositing.
void ApplyGameLayerRegisters(const RoomLayerRegisters &registers)
void ApplyLayerMerging(const LayerMergeType &merge_type)
void ApplyRoomEffect(EffectKey effect)
void MarkLayoutDirty()
Definition room.h:517
void MarkObjectsDirty()
Definition room.h:471
void set_water_fill_sram_bit_mask(uint8_t mask)
Definition room.h:647
bool HasUnsavedChanges() const
Definition room.h:662
void MarkGraphicsDirty()
Definition room.h:512
bool IsLoaded() const
Definition room.h:970
void ClearWaterFillZone()
Definition room.h:634
void LoadRoomGraphics(std::optional< uint8_t > entrance_blockset=std::nullopt)
Definition room.cc:953
void ClearWaterFillDirty()
Definition room.h:655
auto blocks() const
Definition room.h:1080
int ResolveDungeonPaletteId() const
Definition room.cc:921
void SetGameData(GameData *data)
Definition room.h:1088
uint8_t resolved_main_blockset() const
Definition room.h:1017
void SetWaterFillTile(int x, int y, bool filled)
Definition room.h:611
#define ICON_MD_ROCKET_LAUNCH
Definition icons.h:1612
#define ICON_MD_GRID_VIEW
Definition icons.h:897
#define ICON_MD_CHECK
Definition icons.h:397
#define ICON_MD_CASTLE
Definition icons.h:380
#define ICON_MD_GRID_ON
Definition icons.h:896
#define ICON_MD_LIST
Definition icons.h:1094
#define ICON_MD_PENDING
Definition icons.h:1398
#define ICON_MD_DOOR_FRONT
Definition icons.h:613
#define ICON_MD_IMAGE
Definition icons.h:982
#define ICON_MD_PALETTE
Definition icons.h:1370
#define ICON_MD_CATEGORY
Definition icons.h:382
#define ICON_MD_WORKSPACES
Definition icons.h:2186
#define LOG_DEBUG(category, format,...)
Definition log.h:133
#define LOG_ERROR(category, format,...)
Definition log.h:140
#define LOG_WARN(category, format,...)
Definition log.h:138
#define LOG_INFO(category, format,...)
Definition log.h:136
const AgentUITheme & GetTheme()
bool ProjectAdvertisesMinecartTracks(const project::YazeProject *project)
WindowPresentationPolicy WorkbenchTool(std::string capability=std::string())
auto GraphicsObjectStamp(const zelda3::RoomObject &object)
bool IsTransientDungeonRoomWindowId(const std::string &card_id)
bool SameGraphicsEntrance(const DungeonRenderContext &a, const DungeonRenderContext &b)
PreviewImage SnapshotBitmap(const gfx::Bitmap &bitmap)
Copies a palettized Bitmap (for example a room composite).
constexpr const char * kStandalone
Editors are the view controllers for the application.
absl::Status ValidateHackManifestSaveConflicts(const core::HackManifest &manifest, project::RomWritePolicy write_policy, const std::vector< std::pair< uint32_t, uint32_t > > &ranges, absl::string_view save_scope, const char *log_tag, ToastManager *toast_manager)
absl::Status UpdateGraphicsContextPalettes(GraphicsEditContext &context, GraphicsEditContext refreshed)
const char * DungeonRenderContextSourceName(DungeonRenderContextSource source)
std::string PrintShortcut(const std::vector< ImGuiKey > &keys)
absl::StatusOr< GraphicsEditContext > MakeDungeonGraphicsEditContext(const zelda3::Room &room, const zelda3::RoomObject &object, const zelda3::GameData *owner)
RoomSelectionIntent
Intent for room selection in the dungeon editor.
std::string MakePanelTitle(const std::string &title)
Room LoadRoomHeaderFromRom(Rom *rom, int room_id)
Definition room.cc:680
std::string GetRoomLabel(int id)
Convenience function to get a room label.
absl::StatusOr< std::vector< WaterFillZoneEntry > > LoadLegacyWaterGateZones(Rom *rom, const std::string &symbol_path)
gfx::PaletteGroup BuildDungeonRenderPaletteGroupFromGameData(const gfx::SnesPalette &dungeon_palette, const GameData *game_data)
Definition room.cc:191
constexpr int kNumDungeonSpawnPoints
std::unique_ptr< DungeonEditorSystem > CreateDungeonEditorSystem(Rom *rom, GameData *game_data)
Factory function to create dungeon editor system.
absl::StatusOr< std::vector< WaterFillZoneEntry > > LoadWaterFillTable(Rom *rom)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
struct yaze::core::FeatureFlags::Flags::Dungeon dungeon
std::optional< Source > source
Unified dependency container for all editor types.
Definition editor.h:170
project::YazeProject * project
Definition editor.h:174
gfx::IRenderer * renderer
Definition editor.h:192
ShortcutManager * shortcut_manager
Definition editor.h:186
WorkspaceWindowManager * window_manager
Definition editor.h:182
static WindowPresentationPolicy Diagnostic()
static WindowPresentationPolicy CoreWorkspace()
static WindowPresentationPolicy EmbeddedTool(std::string capability=std::string())
WindowPresentationPolicy WithHost(std::string host_name) const
Returns a copy that names the hosting surface.
static WindowPresentationPolicy OptionalPopOut()
Represents a group of palettes.
DungeonRenderPaletteSource source
static constexpr float kStatusBarHeight
Definition ui_config.h:21
Dungeon overlay configuration (per-project).
Definition project.h:94
RomWritePolicy write_policy
Definition project.h:225
Modern project structure with comprehensive settings consolidation.
Definition project.h:287
core::HackManifest hack_manifest
Definition project.h:360
std::string GetAbsolutePath(const std::string &relative_path) const
Definition project.cc:2028
std::string symbols_filename
Definition project.h:315
gfx::PaletteGroupMap palette_groups
Definition game_data.h:95
PitDamageTable pit_damage_table
Definition game_data.h:114