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 <iterator>
12#include <memory>
13#include <string>
14#include <utility>
15#include <vector>
16
17// Third-party library headers
18#include "absl/status/status.h"
19#include "absl/strings/str_format.h"
20#include "absl/types/span.h"
21#include "imgui/imgui.h"
22
23// Project headers
62#include "app/gui/core/icons.h"
64#include "core/features.h"
65#include "core/project.h"
66#include "rom/snes.h"
67#include "util/log.h"
68#include "util/macro.h"
74#include "zelda3/dungeon/room.h"
77
78namespace yaze::editor {
79
80namespace {
81
82bool IsTransientDungeonRoomWindowId(const std::string& card_id) {
83 constexpr const char* kPrefix = "dungeon.room_";
84 constexpr size_t kPrefixLength = 13;
85 if (card_id.rfind(kPrefix, 0) != 0 || card_id.size() <= kPrefixLength) {
86 return false;
87 }
88 return std::all_of(card_id.begin() + kPrefixLength, card_id.end(),
89 [](unsigned char ch) { return std::isdigit(ch) != 0; });
90}
91
92} // namespace
93
95 : rom_(rom), room_loader_(rom), room_selector_(rom), rooms_(rom) {
97 if (rom) {
99 }
100}
101
103 // Clear viewer references in panels BEFORE room_viewers_ is destroyed.
104 // Panels are owned by WorkspaceWindowManager and outlive this editor, so they need
105 // to have their viewer pointers cleared to prevent dangling pointer access.
108 }
111 }
112 if (door_editor_panel_) {
114 }
117 }
118 if (item_editor_panel_) {
120 }
124 }
125 if (water_fill_panel_) {
128 }
129}
130
132 if (!rom_ || !rom_->is_loaded()) {
133 return absl::FailedPreconditionError("ROM not loaded");
134 }
135
138
139 std::array<zelda3::RoomEntrance, zelda3::kNumDungeonEntranceSlots>
140 refreshed_entrances;
141 std::array<zelda3::DungeonSpawnPoint, zelda3::kNumDungeonSpawnPoints>
142 refreshed_spawn_points;
143 RETURN_IF_ERROR(room_loader_.LoadRoomEntrances(refreshed_entrances));
144 RETURN_IF_ERROR(room_loader_.LoadDungeonSpawnPoints(refreshed_spawn_points));
145
146 struct RefreshedRoom {
147 int room_id;
148 zelda3::Room room;
149 };
150 std::vector<std::pair<int, bool>> materialized_rooms;
152 [&materialized_rooms](int room_id, const zelda3::Room& room) {
153 materialized_rooms.emplace_back(room_id, room.IsLoaded());
154 });
155
156 std::vector<RefreshedRoom> refreshed_rooms;
157 refreshed_rooms.reserve(materialized_rooms.size());
158 for (const auto& [room_id, was_loaded] : materialized_rooms) {
159 zelda3::Room refreshed_room;
160 if (was_loaded) {
161 RETURN_IF_ERROR(room_loader_.LoadRoom(room_id, refreshed_room));
162 } else {
163 refreshed_room = zelda3::LoadRoomHeaderFromRom(rom_, room_id);
164 refreshed_room.SetGameData(game_data_);
165 }
166 refreshed_rooms.push_back({room_id, std::move(refreshed_room)});
167 }
168
169 // All ROM reads succeeded. Clear every interaction that can retain indices
170 // into Room vectors before replacing any Room contents.
174 }
177 }
178 auto prepare_viewer = [this](DungeonCanvasViewer* viewer, int room_id) {
179 if (viewer) {
180 viewer->RefreshRomBackedState(rom_, game_data_, &rooms_, room_id);
181 }
182 };
183 room_viewers_.ForEach(
184 [&prepare_viewer](int room_id,
185 std::unique_ptr<DungeonCanvasViewer>& viewer) {
186 prepare_viewer(viewer.get(), room_id);
187 });
188 prepare_viewer(workbench_viewer_.get(), current_room_id_);
189 const int compare_room_id =
191 IsValidRoomId(workbench_compare_viewer_->current_room_id())
192 ? workbench_compare_viewer_->current_room_id()
194 prepare_viewer(workbench_compare_viewer_.get(), compare_room_id);
195
197 dungeon_editor_system_->ClearHistory();
198 if (auto object_editor = dungeon_editor_system_->GetObjectEditor()) {
199 (void)object_editor->ClearSelection();
200 object_editor->ClearHistory();
201 }
202 }
205 pending_undo_ = {};
206 has_pending_undo_ = false;
209 pending_swap_ = {};
212
216 for (auto& refreshed : refreshed_rooms) {
217 auto* room = rooms_.GetIfMaterialized(refreshed.room_id);
218 if (room) {
219 *room = std::move(refreshed.room);
220 }
221 }
222 entrances_ = std::move(refreshed_entrances);
223 spawn_points_ = std::move(refreshed_spawn_points);
225
228
231 auto* current_room = IsValidRoomId(current_room_id_)
233 : nullptr;
234 RETURN_IF_ERROR(dungeon_editor_system_->RefreshRomBackedState(
235 current_room, current_room_id_));
236 }
237
242 if (auto* current_room = rooms_.GetIfMaterialized(current_room_id_);
243 current_room && game_data_ &&
245 const int resolved_palette_id = current_room->ResolveDungeonPaletteId();
247 resolved_palette_id >= 0 &&
248 resolved_palette_id <
249 static_cast<int>(
251 ? static_cast<uint64_t>(resolved_palette_id)
252 : 0;
258 }
259
260 if (is_loaded_) {
264 }
265
266 auto bind_viewer = [this](DungeonCanvasViewer* viewer, int room_id) {
267 if (!viewer) {
268 return;
269 }
270 viewer->SetRenderer(renderer_);
276 ConfigureViewerRenderContext(viewer, room_id);
278 };
279 room_viewers_.ForEach(
280 [&bind_viewer](int room_id,
281 std::unique_ptr<DungeonCanvasViewer>& viewer) {
282 bind_viewer(viewer.get(), room_id);
283 });
284 bind_viewer(workbench_viewer_.get(), current_room_id_);
285 bind_viewer(workbench_compare_viewer_.get(), compare_room_id);
286
292 }
295 }
298 }
301 }
305 }
306 if (workbench_panel_) {
308 }
309
310 DungeonCanvasViewer* current_viewer = nullptr;
312 current_viewer = workbench_viewer_.get();
313 } else if (auto* viewer = room_viewers_.Get(current_room_id_)) {
314 current_viewer = viewer->get();
315 }
316 if (current_viewer) {
318 }
319
320 return absl::OkStatus();
321}
322
325 room.ClearWaterFillZone();
326 room.ClearWaterFillDirty();
327 });
328
329 bool legacy_imported = false;
330 std::vector<zelda3::WaterFillZoneEntry> zones;
331
332 auto zones_or = zelda3::LoadWaterFillTable(rom_);
333 if (zones_or.ok()) {
334 zones = std::move(zones_or.value());
335 } else {
336 LOG_WARN("DungeonEditorV2", "WaterFillTable parse failed: %s",
337 zones_or.status().message().data());
340 absl::StrFormat("WaterFill table parse failed: %s",
341 zones_or.status().message()),
343 }
344 }
345
346 if (zones.empty()) {
347 std::string sym_path;
352 }
353 auto legacy_or = zelda3::LoadLegacyWaterGateZones(rom_, sym_path);
354 if (legacy_or.ok()) {
355 zones = std::move(legacy_or.value());
356 legacy_imported = !zones.empty();
357 } else {
358 LOG_WARN("DungeonEditorV2", "Legacy water gate import failed: %s",
359 legacy_or.status().message().data());
360 }
361 }
362
363 for (const auto& z : zones) {
364 if (z.room_id < 0 || z.room_id >= static_cast<int>(rooms_.size())) {
365 continue;
366 }
367 auto& room = rooms_[z.room_id];
368 room.set_water_fill_sram_bit_mask(z.sram_bit_mask);
369 for (uint16_t off : z.fill_offsets) {
370 const int x = static_cast<int>(off % 64);
371 const int y = static_cast<int>(off / 64);
372 room.SetWaterFillTile(x, y, true);
373 }
374
375 if (!legacy_imported) {
376 room.ClearWaterFillDirty();
377 }
378 }
379
380 if (legacy_imported && dependencies_.toast_manager) {
382 "Imported legacy water gate zones (save to write new table)",
384 }
385}
386
390 }
392 const bool rom_changed = dependencies_.rom && dependencies_.rom != rom_;
393 if (rom_changed) {
395 // The system owns ROM-backed views; ensure it matches the current ROM.
397 }
400 if (game_data_) {
402 }
403 }
404
405 // Setup docking class for room windows
406 room_window_class_.DockingAllowUnclassed = true;
407 room_window_class_.DockingAlwaysTabBar = true;
408
410 return;
411 auto* window_manager = dependencies_.window_manager;
412
413 // Legacy panel IDs persisted in older layouts/settings.
414 window_manager->RegisterPanelAlias("dungeon.object_tools", kObjectSelectorId);
415 window_manager->RegisterPanelAlias("dungeon.entrances",
416 "dungeon.entrance_properties");
417
418 // Register panels with WorkspaceWindowManager (no boolean flags - visibility is
419 // managed entirely by WorkspaceWindowManager::ShowPanel/HidePanel/IsPanelVisible)
420 window_manager->RegisterPanel(
421 {.card_id = "dungeon.workbench",
422 .display_name = "Dungeon Workbench",
423 .window_title = " Dungeon Workbench",
424 .icon = ICON_MD_WORKSPACES,
425 .category = "Dungeon",
426 .shortcut_hint = "Ctrl+Shift+W",
427 .visibility_flag = nullptr,
428 .priority = 5,
429 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
430 .disabled_tooltip = "Load a ROM to edit dungeon rooms"});
431
432 window_manager->RegisterPanel(
433 {.card_id = kRoomSelectorId,
434 .display_name = "Room List",
435 .window_title = " Room List",
436 .icon = ICON_MD_LIST,
437 .category = "Dungeon",
438 .shortcut_hint = "Ctrl+Shift+R",
439 .visibility_flag = nullptr,
440 .priority = 20,
441 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
442 .disabled_tooltip = "Load a ROM to browse dungeon rooms"});
443
444 window_manager->RegisterPanel(
445 {.card_id = kEntranceListId,
446 .display_name = "Entrance List",
447 .window_title = " Entrance List",
448 .icon = ICON_MD_DOOR_FRONT,
449 .category = "Dungeon",
450 .shortcut_hint = "Ctrl+Shift+E",
451 .visibility_flag = nullptr,
452 .priority = 25,
453 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
454 .disabled_tooltip = "Load a ROM to browse dungeon entrances"});
455
456 window_manager->RegisterPanel(
457 {.card_id = "dungeon.entrance_properties",
458 .display_name = "Entrance Properties",
459 .window_title = " Entrance Properties",
460 .icon = ICON_MD_TUNE,
461 .category = "Dungeon",
462 .shortcut_hint = "",
463 .visibility_flag = nullptr,
464 .priority = 26,
465 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
466 .disabled_tooltip = "Load a ROM to edit entrance properties"});
467
468 window_manager->RegisterPanel(
469 {.card_id = kRoomMatrixId,
470 .display_name = "Room Matrix",
471 .window_title = " Room Matrix",
472 .icon = ICON_MD_GRID_VIEW,
473 .category = "Dungeon",
474 .shortcut_hint = "Ctrl+Shift+M",
475 .visibility_flag = nullptr,
476 .priority = 30,
477 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
478 .disabled_tooltip = "Load a ROM to view the room matrix"});
479
480 window_manager->RegisterPanel(
481 {.card_id = kRoomGraphicsId,
482 .display_name = "Room Graphics",
483 .window_title = " Room Graphics",
484 .icon = ICON_MD_IMAGE,
485 .category = "Dungeon",
486 .shortcut_hint = "Ctrl+Shift+G",
487 .visibility_flag = nullptr,
488 .priority = 50,
489 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
490 .disabled_tooltip = "Load a ROM to view room graphics"});
491
492 window_manager->RegisterPanel(
493 {.card_id = kObjectSelectorId,
494 .display_name = "Object Selector",
495 .window_title = " Object Selector",
496 .icon = ICON_MD_CATEGORY,
497 .category = "Dungeon",
498 .shortcut_hint = "",
499 .visibility_flag = nullptr,
500 .priority = 60,
501 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
502 .disabled_tooltip = "Load a ROM to browse dungeon objects"});
503
504 window_manager->RegisterPanel(
505 {.card_id = kDoorEditorId,
506 .display_name = "Door Editor",
507 .window_title = " Door Editor",
508 .icon = ICON_MD_DOOR_FRONT,
509 .category = "Dungeon",
510 .shortcut_hint = "",
511 .visibility_flag = nullptr,
512 .priority = 69,
513 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
514 .disabled_tooltip = "Load a ROM to edit dungeon doors"});
515
516 window_manager->RegisterPanel(
517 {.card_id = kPaletteEditorId,
518 .display_name = "Palette Editor",
519 .window_title = " Palette Editor",
520 .icon = ICON_MD_PALETTE,
521 .category = "Dungeon",
522 // Avoid conflicting with the global Command Palette (Ctrl/Cmd+Shift+P).
523 .shortcut_hint = "Ctrl+Shift+Alt+P",
524 .visibility_flag = nullptr,
525 .priority = 70,
526 .enabled_condition = [this]() { return rom_ && rom_->is_loaded(); },
527 .disabled_tooltip = "Load a ROM to edit dungeon palettes"});
528
529 // Show default panels on startup.
530 // Workbench mode intentionally suppresses parallel standalone defaults to
531 // avoid two competing workflows being open at once.
533 /*show_toast=*/false);
534
535 // Wire intent-aware callback for double-click / context menu
537 [this](int room_id, RoomSelectionIntent intent) {
538 OnRoomSelected(room_id, intent);
539 });
540
541 // Register WindowContent instances
542 window_manager->RegisterWindowContent(std::make_unique<RoomBrowserContent>(
543 &room_selector_, [this](int room_id) { OnRoomSelected(room_id); }));
544
545 window_manager->RegisterWindowContent(
546 std::make_unique<DungeonEntranceListPanel>(
548 [this](int entrance_id) { OnEntranceSelected(entrance_id); }));
549
550 {
551 auto matrix_panel = std::make_unique<RoomMatrixContent>(
553 [this](int room_id) { OnRoomSelected(room_id); },
554 [this](int old_room, int new_room) {
555 SwapRoomInPanel(old_room, new_room);
556 },
557 &rooms_);
558 matrix_panel->SetRoomIntentCallback(
559 [this](int room_id, RoomSelectionIntent intent) {
560 OnRoomSelected(room_id, intent);
561 });
562 window_manager->RegisterWindowContent(std::move(matrix_panel));
563 }
564
565 {
566 auto workbench = std::make_unique<DungeonWorkbenchContent>(
568 [this](int room_id) { OnRoomSelected(room_id); },
569 [this](int room_id, RoomSelectionIntent intent) {
570 OnRoomSelected(room_id, intent);
571 },
572 [this](int room_id) {
573 auto status = SaveRoom(room_id);
574 if (!status.ok()) {
575 LOG_ERROR("DungeonEditorV2", "Apply Room failed: %s",
576 status.message().data());
579 absl::StrFormat("Apply Room failed: %s", status.message()),
581 }
582 return;
583 }
585 dependencies_.toast_manager->Show("Applied room to ROM buffer",
587 }
588 },
589 [this]() { SaveAllRooms(); }, [this]() { return GetWorkbenchViewer(); },
590 [this](int room_id) { return GetWorkbenchCompareViewer(room_id); },
591 [this]() -> const std::deque<int>& { return recent_rooms_; },
592 [this](int room_id) {
593 recent_rooms_.erase(
594 std::remove(recent_rooms_.begin(), recent_rooms_.end(), room_id),
595 recent_rooms_.end());
596 },
597 [this](bool enabled) { QueueWorkbenchWorkflowMode(enabled); }, rom_);
598 workbench_panel_ = workbench.get();
600 [this]() { return undo_manager_.CanUndo(); },
601 [this]() { return undo_manager_.CanRedo(); },
602 [this]() { Undo().IgnoreError(); }, [this]() { Redo().IgnoreError(); },
603 [this]() { return undo_manager_.GetUndoDescription(); },
604 [this]() { return undo_manager_.GetRedoDescription(); },
605 [this]() { return static_cast<int>(undo_manager_.UndoStackSize()); });
606 workbench_panel_->SetOnInspectorSideChanged([this](bool on_left) {
609 : "right");
610 dependencies_.user_settings->Save().IgnoreError();
611 }
612 });
614 return game_data_ ? &game_data_->pit_damage_table : nullptr;
615 });
616 window_manager->RegisterWindowContent(std::move(workbench));
617 }
618
619 window_manager->RegisterWindowContent(std::make_unique<DungeonEntrancesPanel>(
621 [this](int entrance_id) { OnEntranceSelected(entrance_id); }));
622
623 // Note: RoomGraphicsContent and PaletteEditorContent are registered
624 // in Load() after their dependencies (renderer_, palette_editor_) are initialized
625}
626
627absl::Status DungeonEditorV2::Load() {
628 if (!rom_ || !rom_->is_loaded()) {
629 return absl::FailedPreconditionError("ROM not loaded");
630 }
631
632 // Initialize ObjectDimensionTable so DimensionService's fallback path works.
633 // DimensionService::Get() queries this table internally but has no Load API.
634 auto& dim_table = zelda3::ObjectDimensionTable::Get();
635 if (!dim_table.IsLoaded()) {
636 RETURN_IF_ERROR(dim_table.LoadFromRom(rom_));
637 }
638
641
642 if (!game_data()) {
643 return absl::FailedPreconditionError("GameData not available");
644 }
645 auto dungeon_main_pal_group = game_data()->palette_groups.dungeon_main;
646 current_palette_ = dungeon_main_pal_group[current_palette_group_id_];
649
654 [this](int room_id) { OnRoomSelected(room_id); });
656 [this](int room_id, RoomSelectionIntent intent) {
657 OnRoomSelected(room_id, intent);
658 });
660 [this](int entrance_id) { OnEntranceSelected(entrance_id); });
661
662 // Canvas viewers are lazily created in GetViewerForRoom
663
664 if (game_data()) {
666 } else {
668 }
669
672
673 // Register panels that depend on initialized state (renderer, palette_editor_)
675 auto graphics_panel = std::make_unique<RoomGraphicsContent>(
677 room_graphics_panel_ = graphics_panel.get();
679 std::move(graphics_panel));
680 auto palette_panel =
681 std::make_unique<PaletteEditorContent>(&palette_editor_);
682 palette_editor_panel_ = palette_panel.get();
684 std::move(palette_panel));
685 }
686
687 dungeon_editor_system_ = std::make_unique<zelda3::DungeonEditorSystem>(rom_);
688 (void)dungeon_editor_system_->Initialize();
690
691 // Initialize browse-only object selector and the dedicated object editor.
692 auto object_selector = std::make_unique<ObjectSelectorContent>(
693 rom_, nullptr, dungeon_editor_system_->GetObjectEditor(),
695 auto object_editor = std::make_unique<ObjectEditorContent>(
696 dungeon_editor_system_->GetObjectEditor());
697
698 // Wire up object change callback to trigger room re-rendering
699 dungeon_editor_system_->GetObjectEditor()->SetObjectChangedCallback(
700 [this](size_t /*object_index*/, const zelda3::RoomObject& /*object*/) {
701 if (current_room_id_ >= 0 && current_room_id_ < (int)rooms_.size()) {
702 rooms_[current_room_id_].RenderRoomGraphics();
703 }
704 });
705
706 // Set rooms and initial palette group for correct preview rendering
707 object_selector->SetRooms(&rooms_);
708 object_selector->SetCurrentPaletteGroup(current_palette_group_);
709
710 auto open_workbench_selection_inspector = [this]() {
711 if (workbench_panel_) {
713 }
714 OpenWindow("dungeon.workbench");
715 };
716
717 object_selector->SetOpenObjectEditorCallback(
718 open_workbench_selection_inspector);
719
720 // Keep raw pointers for later access
721 object_selector_panel_ = object_selector.get();
722 object_editor_content_ = object_editor.get();
723
724 auto jump_to_reciprocal_door = [this](int neighbor_room_id,
725 size_t door_index) {
726 if (neighbor_room_id < 0 ||
727 neighbor_room_id >= static_cast<int>(rooms_.size())) {
728 return;
729 }
730 OnRoomSelected(neighbor_room_id, true);
731 if (auto* viewer = GetViewerForRoom(neighbor_room_id)) {
732 viewer->object_interaction()
733 .entity_coordinator()
734 .door_handler()
735 .SelectDoor(door_index);
736 }
737 };
738 object_editor->SetJumpToReciprocalDoorCallback(jump_to_reciprocal_door);
739 owned_object_editor_content_ = std::move(object_editor);
740
741 auto door_editor = std::make_unique<DoorEditorContent>();
742 door_editor->SetRooms(&rooms_);
743 door_editor->SetOpenSelectionInspectorCallback(
744 open_workbench_selection_inspector);
745 door_editor->SetJumpToReciprocalDoorCallback(jump_to_reciprocal_door);
746 door_editor_panel_ = door_editor.get();
747
748 // Propagate game_data to the object editor panel if available
749 if (game_data()) {
751 }
756 }
757
758 // Room-scoped utility tools are embedded in the Workbench drawer by default,
759 // but the same WindowContent instances remain available in the explicit
760 // Window workflow. Workbench mode closes and hides these entries so they do
761 // not compete with the integrated drawer.
762 auto custom_collision_panel =
763 std::make_unique<CustomCollisionPanel>(nullptr, nullptr);
764 custom_collision_panel_ = custom_collision_panel.get();
765
766 auto water_fill_panel = std::make_unique<WaterFillPanel>(nullptr, nullptr);
767 water_fill_panel_ = water_fill_panel.get();
768
769 auto room_tag_panel = std::make_unique<RoomTagEditorPanel>();
770 room_tag_panel->SetProject(dependencies_.project);
771 room_tag_panel->SetRooms(&rooms_);
772 room_tag_panel->SetCurrentRoomId(current_room_id_);
773 room_tag_editor_panel_ = room_tag_panel.get();
774
775 std::unique_ptr<MinecartTrackEditorPanel> minecart_panel;
776 if (core::FeatureFlags::get().kEnableCustomObjects) {
777 minecart_panel = std::make_unique<MinecartTrackEditorPanel>();
778 minecart_track_editor_panel_ = minecart_panel.get();
779
787 [this](int room_id) { OnRoomSelected(room_id); });
788 }
789 } else {
791 }
792
794 // Initialize custom object manager with project-configured path.
799 } else {
800 // Avoid inheriting stale singleton state from previous projects.
802 }
803 }
804
805 // Register the ObjectSelectorContent directly (it inherits from WindowContent)
806 // Panel manager takes ownership
809 std::move(object_selector));
810 dependencies_.window_manager->RegisterWindowContent(std::move(door_editor));
811
812 // Register sprite and item editor panels with canvas viewer = nullptr
813 // They will get the viewer reference in OnRoomSelected when a room is selected
814 auto sprite_panel = std::make_unique<SpriteEditorPanel>(&current_room_id_,
815 &rooms_, nullptr);
816 sprite_panel->SetOpenSelectionInspectorCallback(
817 open_workbench_selection_inspector);
818 sprite_editor_panel_ = sprite_panel.get();
820 std::move(sprite_panel));
821
822 auto item_panel =
823 std::make_unique<ItemEditorPanel>(&current_room_id_, &rooms_, nullptr);
824 item_panel->SetOpenSelectionInspectorCallback(
825 open_workbench_selection_inspector);
826 item_editor_panel_ = item_panel.get();
827 dependencies_.window_manager->RegisterWindowContent(std::move(item_panel));
828
830 std::move(custom_collision_panel));
832 std::move(water_fill_panel));
834 std::move(room_tag_panel));
835 if (minecart_panel) {
837 std::move(minecart_panel));
838 }
839
840 // Object Tile Editor Panel
841 {
842 auto tile_editor_panel =
843 std::make_unique<ObjectTileEditorPanel>(renderer_, rom_);
844 tile_editor_panel->SetCurrentPaletteGroup(current_palette_group_);
845
846 // Wire creation callback: when a new custom object is saved,
847 // register it with the manager, persist to project, and refresh UI.
848 tile_editor_panel->SetObjectCreatedCallback(
849 [this](int object_id, const std::string& filename) {
851 filename);
853 dependencies_.project->custom_object_files[object_id].push_back(
854 filename);
855 (void)dependencies_.project->Save();
856 }
860 }
861 });
862
863 object_tile_editor_panel_ = tile_editor_panel.get();
865 std::move(tile_editor_panel));
866 }
867
868 // Wire tile editor panel and project references to the object selector
875 }
876 }
877
878 // Overlay Manager Panel
879 {
880 auto overlay_panel = std::make_unique<OverlayManagerPanel>();
881 overlay_manager_panel_ = overlay_panel.get();
883 std::move(overlay_panel));
884 }
885
886 } else {
887 owned_object_selector_panel_ = std::move(object_selector);
888 owned_door_editor_panel_ = std::move(door_editor);
889 owned_custom_collision_panel_ = std::move(custom_collision_panel);
890 owned_water_fill_panel_ = std::move(water_fill_panel);
891 owned_room_tag_editor_panel_ = std::move(room_tag_panel);
892 owned_minecart_track_editor_panel_ = std::move(minecart_panel);
893 }
894
896 [this](gui::DungeonPaletteChange change) {
898
899 auto apply_palette = [this](DungeonCanvasViewer* viewer) {
900 if (!viewer) {
901 return;
902 }
903 viewer->SetCurrentPaletteId(current_palette_id_);
904 viewer->SetCurrentPaletteGroup(current_palette_group_);
905 };
906
907 if (current_room_id_ >= 0 && current_room_id_ < (int)rooms_.size() &&
908 game_data()) {
909 auto& dungeon_main_pal_group =
911 if (current_palette_id_ >= 0 &&
913 static_cast<int>(dungeon_main_pal_group.size())) {
915 current_palette_ = dungeon_main_pal_group[current_palette_id_];
916 if (auto pal_group =
918 pal_group.ok()) {
919 current_palette_group_ = pal_group.value();
920 apply_palette(workbench_viewer_.get());
921 apply_palette(workbench_compare_viewer_.get());
923 if (auto* existing_viewer =
925 apply_palette(existing_viewer->get());
926 }
927 }
931 }
935 }
936 }
937 }
938 }
939
940 bool rendered_current_room = false;
941 for (int i = 0; i < active_rooms_.Size; i++) {
942 int room_id = active_rooms_[i];
943 if (room_id >= 0 && room_id < (int)rooms_.size()) {
944 rooms_[room_id].RenderRoomGraphics();
945 rendered_current_room |= room_id == current_room_id_;
946 }
947 }
948
949 if (!rendered_current_room && current_room_id_ >= 0 &&
950 current_room_id_ < static_cast<int>(rooms_.size())) {
951 rooms_[current_room_id_].RenderRoomGraphics();
952 }
953 });
954
955 // Oracle of Secrets: load editor-authored water fill zones (best-effort).
957
958 if (workbench_panel_) {
965 }
966
967 is_loaded_ = true;
968 return absl::OkStatus();
969}
970
975 [](int, zelda3::Room& room) { room.MarkGraphicsDirty(); });
976 return;
977 }
978
979 if (change.palette_id < 0) {
980 return;
981 }
982
983 // Editing palette bytes does not change a room header, so the room's normal
984 // property cache cannot detect this dependency change. Mark every cached
985 // room that resolves to the edited concrete palette; inactive rooms will
986 // redraw lazily the next time they are shown.
987 rooms_.ForEachMaterialized([change](int, zelda3::Room& room) {
988 if (room.ResolveDungeonPaletteId() == change.palette_id) {
989 room.MarkGraphicsDirty();
990 }
991 });
992}
993
996
997 // Mirror the workbench inspector to the LEFT when user_settings says so.
998 // Persisted across sessions via UserSettings::dungeon_inspector_side.
1002 }
1003
1004 const auto& theme = AgentUI::GetTheme();
1005 if (room_window_class_.ClassId == 0) {
1006 room_window_class_.ClassId = ImGui::GetID("DungeonRoomClass");
1007 }
1008
1009 if (!is_loaded_) {
1010 gui::PanelWindow loading_card("Dungeon Editor Loading", ICON_MD_CASTLE);
1011 loading_card.SetDefaultSize(400, 200);
1012 if (loading_card.Begin()) {
1013 ImGui::TextColored(theme.text_secondary_gray,
1014 tr("Loading dungeon data..."));
1015 ImGui::TextWrapped(
1016 tr("Independent editor cards will appear once ROM data is loaded."));
1017 }
1018 loading_card.End();
1019 return absl::OkStatus();
1020 }
1021
1022 if (!IsWorkbenchWorkflowEnabled() || active_rooms_.Size > 0) {
1024 }
1025
1026 if (ImGui::IsKeyPressed(ImGuiKey_Delete)) {
1027 // Delegate delete to current room viewer
1028 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
1029 viewer->DeleteSelectedObjects();
1030 }
1031 }
1032
1033 // Keyboard Shortcuts (only if not typing in a text field)
1034 if (!ImGui::GetIO().WantTextInput) {
1035 if (ImGui::GetIO().KeyCtrl && ImGui::GetIO().KeyShift &&
1036 ImGui::IsKeyPressed(ImGuiKey_W, false)) {
1038 }
1039
1040 // Room Cycling (Ctrl+Tab)
1041 if (ImGui::IsKeyPressed(ImGuiKey_Tab) && ImGui::GetIO().KeyCtrl) {
1043 if (recent_rooms_.size() > 1) {
1044 int current_idx = -1;
1045 for (int i = 0; i < static_cast<int>(recent_rooms_.size()); ++i) {
1046 if (recent_rooms_[i] == current_room_id_) {
1047 current_idx = i;
1048 break;
1049 }
1050 }
1051 if (current_idx != -1) {
1052 int next_idx;
1053 if (ImGui::GetIO().KeyShift) {
1054 next_idx =
1055 (current_idx + 1) % static_cast<int>(recent_rooms_.size());
1056 } else {
1057 next_idx =
1058 (current_idx - 1 + static_cast<int>(recent_rooms_.size())) %
1059 static_cast<int>(recent_rooms_.size());
1060 }
1061 OnRoomSelected(recent_rooms_[next_idx]);
1062 }
1063 }
1064 } else if (active_rooms_.size() > 1) {
1065 int current_idx = -1;
1066 for (int i = 0; i < active_rooms_.size(); ++i) {
1067 if (active_rooms_[i] == current_room_id_) {
1068 current_idx = i;
1069 break;
1070 }
1071 }
1072
1073 if (current_idx != -1) {
1074 int next_idx;
1075 if (ImGui::GetIO().KeyShift) {
1076 next_idx =
1077 (current_idx - 1 + active_rooms_.size()) % active_rooms_.size();
1078 } else {
1079 next_idx = (current_idx + 1) % active_rooms_.size();
1080 }
1081 OnRoomSelected(active_rooms_[next_idx]);
1082 }
1083 }
1084 }
1085
1086 // Adjacent Room Navigation (Ctrl+Arrows)
1087 if (ImGui::GetIO().KeyCtrl) {
1088 int next_room = -1;
1089 const int kCols = 16;
1090
1091 if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
1092 if (current_room_id_ >= kCols)
1093 next_room = current_room_id_ - kCols;
1094 } else if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
1095 if (IsValidRoomId(current_room_id_ + kCols))
1096 next_room = current_room_id_ + kCols;
1097 } else if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow)) {
1098 if (current_room_id_ % kCols > 0)
1099 next_room = current_room_id_ - 1;
1100 } else if (ImGui::IsKeyPressed(ImGuiKey_RightArrow)) {
1101 if (current_room_id_ % kCols < kCols - 1 &&
1103 next_room = current_room_id_ + 1;
1104 }
1105
1106 if (next_room != -1) {
1108 OnRoomSelected(next_room, /*request_focus=*/false);
1109 } else {
1111 }
1112 }
1113 }
1114 }
1115
1116 // Process any pending room swaps after all drawing is complete
1117 // This prevents ImGui state corruption from modifying collections mid-frame
1119
1120 return absl::OkStatus();
1121}
1122
1124 if (!status_bar)
1125 return;
1126 const int room_id = current_room_id();
1127
1128 if (room_id >= 0) {
1129 StatusBarSegmentOptions room_opts;
1130 room_opts.tooltip = "Click to refocus viewer on this room";
1131 room_opts.on_click = [this, room_id]() {
1135 }
1136 };
1137 status_bar->SetCustomSegment("Room", absl::StrFormat("0x%03X", room_id),
1138 std::move(room_opts));
1139 }
1140
1141 const int loaded = LoadedRoomCount();
1142 const int total = TotalRoomCount();
1143 if (total > 0) {
1144 StatusBarSegmentOptions rooms_opts;
1145 rooms_opts.tooltip =
1146 absl::StrFormat("%d rooms loaded of %d total", loaded, total);
1147 status_bar->SetCustomSegment("Rooms",
1148 absl::StrFormat("%d/%d", loaded, total),
1149 std::move(rooms_opts));
1150 }
1151
1152 if (room_id >= 0 && CurrentRoomHasPendingChanges()) {
1153 StatusBarSegmentOptions current_room_opts;
1154 current_room_opts.tooltip =
1155 "This room has pending editor changes that are not yet applied to the "
1156 "ROM buffer.";
1157 status_bar->SetCustomSegment("RoomState", "Pending",
1158 std::move(current_room_opts));
1159 }
1160
1161 StatusBarSegmentOptions mode_opts;
1162 mode_opts.tooltip = "Click to toggle Workbench / Standalone workflow";
1163 mode_opts.on_click = [this]() {
1165 };
1169 std::move(mode_opts));
1170}
1171
1173 return rooms_.LoadedCount();
1174}
1175
1177 int count = 0;
1178 rooms_.ForEachMaterialized([&count](int, const zelda3::Room& room) {
1179 if (room.HasUnsavedChanges()) {
1180 ++count;
1181 }
1182 });
1183 return count;
1184}
1185
1187 return PendingRoomCount() > 0;
1188}
1189
1191 if (current_room_id_ < 0 ||
1192 current_room_id_ >= static_cast<int>(rooms_.size())) {
1193 return false;
1194 }
1195 if (const auto* room = rooms_.GetIfMaterialized(current_room_id_)) {
1196 return room->HasUnsavedChanges();
1197 }
1198 return false;
1199}
1200
1202 if (!core::FeatureFlags::get().dungeon.kUseWorkbench) {
1203 return false;
1204 }
1206 return true;
1207 }
1208 return dependencies_.window_manager->IsWindowOpen("dungeon.workbench");
1209}
1210
1211void DungeonEditorV2::SetWorkbenchWorkflowMode(bool enabled, bool show_toast) {
1212 auto* window_manager = dependencies_.window_manager;
1213 if (!window_manager) {
1214 return;
1215 }
1216
1217 const size_t session_id = window_manager->GetActiveSessionId();
1218 const bool was_enabled = IsWorkbenchWorkflowEnabled();
1219
1220 if (enabled) {
1221 window_manager->OpenWindow(session_id, "dungeon.workbench");
1222
1223 // Workbench-local tool windows (Object/Door/Sprite/Item Selector, Palette,
1224 // Room Graphics/Tags, Custom Collision, Water Fill, Minecart) are NOT
1225 // auto-closed on Workbench entry. The Workbench drawer embeds the same
1226 // tools, but users may also keep a standalone window open if they prefer
1227 // a multi-window layout. Only navigation windows (room selector/matrix
1228 // and per-room windows) are collapsed when entering Workbench mode.
1229 for (const auto& descriptor :
1230 window_manager->GetWindowsInCategory(session_id, "Dungeon")) {
1231 const std::string& card_id = descriptor.card_id;
1232 if (card_id == "dungeon.workbench") {
1233 continue;
1234 }
1235 if (window_manager->IsWindowPinned(session_id, card_id)) {
1236 continue;
1237 }
1238 const bool is_room_window = IsTransientDungeonRoomWindowId(card_id);
1239 if (card_id == kRoomSelectorId || card_id == kRoomMatrixId ||
1240 is_room_window) {
1241 window_manager->CloseWindow(session_id, card_id);
1242 }
1243 }
1244 } else {
1245 window_manager->CloseWindow(session_id, "dungeon.workbench");
1246 window_manager->OpenWindow(session_id, kRoomSelectorId);
1247 window_manager->OpenWindow(session_id, kRoomMatrixId);
1248 if (current_room_id_ >= 0) {
1250 }
1251 }
1252
1253 if (show_toast && dependencies_.toast_manager && was_enabled != enabled) {
1255 enabled ? "Dungeon workflow: Workbench"
1256 : "Dungeon workflow: Standalone Panels",
1258 }
1259}
1260
1262 bool show_toast) {
1266}
1267
1271
1273 for (int i = 0; i < active_rooms_.Size; i++) {
1274 int room_id = active_rooms_[i];
1275 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
1276 bool panel_visible = true;
1278 panel_visible = dependencies_.window_manager->IsWindowOpen(card_id);
1279 }
1280
1281 if (!panel_visible) {
1283 room_cards_.erase(room_id);
1284 active_rooms_.erase(active_rooms_.Data + i);
1285 ReleaseRoomPanelSlotId(room_id);
1286 // Clean up viewer
1287 room_viewers_.Erase(room_id);
1288 i--;
1289 continue;
1290 }
1291
1292 bool is_pinned = dependencies_.window_manager &&
1294 std::string active_category =
1297 : "";
1298
1299 if (active_category != "Dungeon" && !is_pinned) {
1300 continue;
1301 }
1302
1303 // Ensure room card exists (should have been created by ShowRoomPanel/ShowPanel)
1304 if (room_cards_.find(room_id) == room_cards_.end()) {
1305 ShowRoomPanel(room_id);
1306 }
1307
1308 auto& room_card = room_cards_[room_id];
1309 bool open = true;
1310
1311 ImGui::SetNextWindowClass(&room_window_class_);
1312 if (room_dock_id_ == 0) {
1313 room_dock_id_ = ImGui::GetID("DungeonRoomDock");
1314 }
1315 ImGui::SetNextWindowDockID(room_dock_id_, ImGuiCond_FirstUseEver);
1316
1317 if (room_card->Begin(&open)) {
1318 if (ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) {
1319 OnRoomSelected(room_id, /*request_focus=*/false);
1320 }
1321 DrawRoomTab(room_id);
1322 }
1323 room_card->End();
1324
1325 if (!open) {
1328 }
1329
1330 room_cards_.erase(room_id);
1331 active_rooms_.erase(active_rooms_.Data + i);
1332 room_viewers_.Erase(room_id);
1333 ReleaseRoomPanelSlotId(room_id);
1334 i--;
1335 }
1336 }
1337}
1338
1340 if (auto it = room_panel_slot_ids_.find(room_id);
1341 it != room_panel_slot_ids_.end()) {
1342 return it->second;
1343 }
1344 const int slot_id = next_room_panel_slot_id_++;
1345 room_panel_slot_ids_[room_id] = slot_id;
1346 return slot_id;
1347}
1348
1350 room_panel_slot_ids_.erase(room_id);
1351}
1352
1354 const auto& theme = AgentUI::GetTheme();
1355 if (room_id < 0 || room_id >= 0x128) {
1356 ImGui::Text(tr("Invalid room ID: %d"), room_id);
1357 return;
1358 }
1359
1360 auto& room = rooms_[room_id];
1361
1362 if (!room.IsLoaded()) {
1363 auto status = room_loader_.LoadRoom(room_id, room);
1364 if (!status.ok()) {
1365 ImGui::TextColored(theme.text_error_red, tr("Failed to load room: %s"),
1366 status.message().data());
1367 return;
1368 }
1369
1371 auto sys_status = dungeon_editor_system_->ReloadRoom(room_id);
1372 if (!sys_status.ok()) {
1373 LOG_ERROR("DungeonEditorV2", "Failed to load system data: %s",
1374 sys_status.message().data());
1375 }
1376 }
1377 }
1378
1379 if (room.IsLoaded()) {
1380 bool needs_render = false;
1381
1382 if (room.blocks().empty()) {
1384 room.LoadRoomGraphics();
1385 needs_render = true;
1386 }
1387
1388 if (!room.AreObjectsLoaded()) {
1389 room.LoadObjects();
1390 needs_render = true;
1391 }
1392
1393 auto& bg1_bitmap = room.bg1_buffer().bitmap();
1394 if (needs_render || !bg1_bitmap.is_active() || bg1_bitmap.width() == 0) {
1395 room.RenderRoomGraphics();
1396 }
1397 }
1398
1399 if (room.IsLoaded()) {
1400 ImGui::TextColored(theme.text_success_green, ICON_MD_CHECK " Loaded");
1401 } else {
1402 ImGui::TextColored(theme.text_error_red, ICON_MD_PENDING " Not Loaded");
1403 }
1404 ImGui::SameLine();
1405 ImGui::TextDisabled(tr("Objects: %zu"), room.GetTileObjects().size());
1406
1407 if (core::FeatureFlags::get().dungeon.kUseWorkbench &&
1409 ImGui::SameLine();
1410 const std::string return_label = absl::StrFormat(
1411 ICON_MD_WORKSPACES " Workbench##return_workbench_%03X", room_id);
1412 if (ImGui::SmallButton(return_label.c_str())) {
1414 }
1415 if (ImGui::IsItemHovered()) {
1416 ImGui::SetTooltip(
1417 tr("Switch back to the integrated Dungeon Workbench workflow "
1418 "(Ctrl+Shift+W)."));
1419 }
1420 }
1421
1422 // Warp to Room button — sends room ID to running Mesen2 emulator
1423 ImGui::SameLine();
1424 {
1426 bool connected = client && client->IsConnected();
1427 if (!connected) {
1428 ImGui::BeginDisabled();
1429 }
1430 std::string warp_label =
1431 absl::StrFormat(ICON_MD_ROCKET_LAUNCH " Warp##warp_%03X", room_id);
1432 if (ImGui::SmallButton(warp_label.c_str())) {
1433 auto status = client->WriteWord(0x7E00A0, static_cast<uint16_t>(room_id));
1434 if (status.ok()) {
1437 absl::StrFormat("Warped to room 0x%03X", room_id),
1439 }
1440 } else {
1443 absl::StrFormat("Warp failed: %s", status.message()),
1445 }
1446 }
1447 }
1448 if (!connected) {
1449 ImGui::EndDisabled();
1450 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1451 ImGui::SetTooltip(tr("Connect to Mesen2 first"));
1452 }
1453 } else {
1454 if (ImGui::IsItemHovered()) {
1455 ImGui::SetTooltip(tr("Warp to room 0x%03X in Mesen2"), room_id);
1456 }
1457 }
1458 }
1459
1460 ImGui::Separator();
1461
1462 // Use per-room viewer; reserve a bottom strip for the shared dungeon
1463 // status bar so this Windows-mode standalone window mirrors the Workbench
1464 // canvas pane's affordances.
1465 if (auto* viewer = GetViewerForRoom(room_id)) {
1466 const float status_bar_reserved =
1467 std::max(ImGui::GetFontSize() + ImGui::GetStyle().FramePadding.y * 2.0f,
1469 ImGui::GetStyle().ItemSpacing.y;
1470 if (ImGui::BeginChild("##StandaloneRoomBody",
1471 ImVec2(0.0f, -status_bar_reserved), false)) {
1472 viewer->DrawDungeonCanvas(room_id);
1473 }
1474 ImGui::EndChild();
1475
1476 bool room_dirty = false;
1477 if (room_id >= 0 && room_id < static_cast<int>(rooms_.size())) {
1478 room_dirty = rooms_[room_id].HasUnsavedChanges();
1479 }
1480 const char* tool_mode =
1481 viewer->object_interaction().mode_manager().GetModeName();
1482 auto status = DungeonStatusBar::BuildState(*viewer, tool_mode, room_dirty);
1483 status.workflow_mode = workflow_mode_names::kStandalone;
1484 status.workflow_primary = false;
1485 status.can_undo = undo_manager_.CanUndo();
1486 status.can_redo = undo_manager_.CanRedo();
1487 status.undo_depth = static_cast<int>(undo_manager_.UndoStackSize());
1488 status.on_undo = [this]() {
1489 Undo().IgnoreError();
1490 };
1491 status.on_redo = [this]() {
1492 Redo().IgnoreError();
1493 };
1494 DungeonStatusBar::Draw(status);
1495 }
1496}
1497
1499 switch (intent) {
1501 OnRoomSelected(room_id, /*request_focus=*/true);
1502 break;
1504 // Explicitly pivot to panel workflow when user asks for standalone room.
1505 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
1506 return;
1507 }
1510 }
1511 // Update shared state (same as OnRoomSelected with request_focus=true)
1512 OnRoomSelected(room_id, /*request_focus=*/false);
1513 // Now force-open a standalone panel for this room
1514 ShowRoomPanel(room_id);
1515 break;
1516 }
1518 OnRoomSelected(room_id, /*request_focus=*/false);
1519 break;
1520 }
1521}
1522
1523void DungeonEditorV2::OnRoomSelected(int room_id, bool request_focus) {
1524 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
1525 LOG_WARN("DungeonEditorV2", "Ignoring invalid room selection: %d", room_id);
1526 return;
1527 }
1528 if (room_id != current_room_id_ && workbench_panel_) {
1530 }
1531 current_room_id_ = room_id;
1532 room_selector_.set_current_room_id(static_cast<uint16_t>(room_id));
1533
1534 // Track recent rooms (remove if already present, add to front)
1535 recent_rooms_.erase(
1536 std::remove(recent_rooms_.begin(), recent_rooms_.end(), room_id),
1537 recent_rooms_.end());
1538 recent_rooms_.push_front(room_id);
1539 if (recent_rooms_.size() > kMaxRecentRooms) {
1540 recent_rooms_.pop_back();
1541 }
1542
1544 dungeon_editor_system_->SetExternalRoom(&rooms_[room_id]);
1545 }
1546
1547 // Update all sub-panels (Object Editor, Sprite Editor, etc.)
1548 SyncPanelsToRoom(room_id);
1549
1550 // Sync palette with current room (must happen before early return for focus changes)
1551
1552 // Sync palette with current room (must happen before early return for focus changes)
1553 if (room_id >= 0 && room_id < (int)rooms_.size()) {
1554 auto& room = rooms_[room_id];
1555 if (!room.IsLoaded()) {
1556 room_loader_.LoadRoom(room_id, room);
1557 }
1558
1559 if (room.IsLoaded()) {
1561 current_palette_id_ = room.ResolveDungeonPaletteId();
1564
1565 // Update viewer and object editor palette
1566 if (auto* viewer = GetViewerForRoom(room_id)) {
1567 ConfigureViewerRenderContext(viewer, room_id);
1568 viewer->SetCurrentPaletteId(current_palette_id_);
1569
1570 if (game_data()) {
1571 auto dungeon_main_pal_group =
1573 if (current_palette_id_ < (int)dungeon_main_pal_group.size()) {
1574 current_palette_ = dungeon_main_pal_group[current_palette_id_];
1575 auto result =
1577 if (result.ok()) {
1578 current_palette_group_ = result.value();
1579 viewer->SetCurrentPaletteGroup(current_palette_group_);
1583 }
1584 // Sync palette to graphics panel for proper sheet coloring
1588 }
1589 }
1590 }
1591 }
1592 }
1593 }
1594 }
1595
1596 // Workbench mode uses a single stable window and does not spawn per-room
1597 // panels. Keep selection + panels in sync and return.
1599 if (dependencies_.window_manager && request_focus) {
1600 // Only force-show if it's already visible or if it's the first initialization
1601 // This avoids obtrusive behavior when the user explicitly closed it.
1602 if (dependencies_.window_manager->IsWindowOpen("dungeon.workbench")) {
1603 dependencies_.window_manager->OpenWindow("dungeon.workbench");
1604 }
1605 }
1606 return;
1607 }
1608
1609 // Check if room is already open
1610 for (int i = 0; i < active_rooms_.Size; i++) {
1611 if (active_rooms_[i] == room_id) {
1612 // Always ensure panel is visible, even if already in active_rooms_
1614 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
1616 }
1617 if (request_focus) {
1618 FocusRoom(room_id);
1619 }
1620 return;
1621 }
1622 }
1623
1624 active_rooms_.push_back(room_id);
1626
1628 // Use unified ResourceLabelProvider for room names
1629 std::string room_name = absl::StrFormat(
1630 "[%03X] %s", room_id, zelda3::GetRoomLabel(room_id).c_str());
1631
1632 std::string base_card_id = absl::StrFormat("dungeon.room_%d", room_id);
1633
1635 {.card_id = base_card_id,
1636 .display_name = room_name,
1637 .window_title = ICON_MD_GRID_ON " " + room_name,
1638 .icon = ICON_MD_GRID_ON,
1639 .category = "Dungeon",
1640 .shortcut_hint = "",
1641 .visibility_flag = nullptr,
1642 .priority = 200 + room_id});
1643
1645 }
1646}
1647
1649 if (entrance_id < 0 || entrance_id >= static_cast<int>(entrances_.size())) {
1650 return;
1651 }
1652 current_entrance_id_ = entrance_id;
1654 int room_id = entrances_[entrance_id].room_;
1655 OnRoomSelected(room_id);
1656}
1657
1659 int room_id) const {
1660 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
1661 return 0xFF;
1662 }
1663 if (current_entrance_id_ < 0 ||
1664 current_entrance_id_ >= static_cast<int>(entrances_.size())) {
1665 return 0xFF;
1666 }
1667 const auto& entrance = entrances_[current_entrance_id_];
1668 if (entrance.room_ != room_id) {
1669 return 0xFF;
1670 }
1671 return entrance.blockset_;
1672}
1673
1675 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
1676 return;
1677 }
1678 auto& room = rooms_[room_id];
1679 if (!room.IsLoaded()) {
1680 return;
1681 }
1682 room.SetRenderEntranceBlockset(
1684}
1685
1687 int room_id) {
1688 if (viewer == nullptr) {
1689 return;
1690 }
1691 const uint8_t entrance_blockset =
1693 if (entrance_blockset == 0xFF) {
1695 } else {
1696 viewer->SetEntranceRenderContext(current_entrance_id_, entrance_blockset);
1697 }
1698}
1699
1700void DungeonEditorV2::add_room(int room_id) {
1701 OnRoomSelected(room_id);
1702}
1703
1705 auto it = room_cards_.find(room_id);
1706 if (it != room_cards_.end()) {
1707 it->second->Focus();
1708 }
1709}
1710
1716 OpenWindow("dungeon.workbench");
1717 } else {
1719 }
1720 }
1721}
1722
1724 if (enabled && dependencies_.window_manager) {
1726 OpenWindow("dungeon.workbench");
1727 if (workbench_panel_) {
1729 }
1730 } else {
1734 }
1737 }
1738 }
1739}
1740
1744
1747 LOG_ERROR("DungeonEditorV2", "Cannot place object: Invalid room ID %d",
1749 const std::string message = absl::StrFormat(
1750 "Cannot place 0x%02X: invalid room %d", obj.id_, current_room_id_);
1753 } else if (dependencies_.toast_manager) {
1755 }
1756 return;
1757 }
1758
1759 auto& room = rooms_[current_room_id_];
1760
1761 LOG_INFO("DungeonEditorV2",
1762 "Placing object ID=0x%02X at position (%d,%d) in room %03X", obj.id_,
1763 obj.x_, obj.y_, current_room_id_);
1764
1765 room.RenderRoomGraphics();
1766 LOG_DEBUG("DungeonEditorV2",
1767 "Object placed and room re-rendered successfully");
1768 // Brief success feedback so the user knows the placement was accepted.
1769 // Kept minimal (no inline panel update) — success is non-spammy by design.
1772 absl::StrFormat("Placed 0x%02X in room %03X", obj.id_,
1775 }
1776}
1777
1779 int room_id, const zelda3::RoomObject& object) {
1780 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
1781 LOG_WARN("DungeonEditorV2", "Edit Graphics ignored (invalid room id %d)",
1782 room_id);
1783 return;
1784 }
1785
1786 auto* editor_manager = static_cast<EditorManager*>(dependencies_.custom_data);
1787 if (!editor_manager) {
1788 LOG_WARN("DungeonEditorV2",
1789 "Edit Graphics ignored (editor manager unavailable)");
1790 return;
1791 }
1792
1793 auto& room = rooms_[room_id];
1795 room.LoadRoomGraphics();
1796
1797 uint16_t sheet_id = 0;
1798 uint16_t tile_index = 0;
1799 bool resolved_sheet = false;
1800 if (auto tiles_or = object.GetTiles();
1801 tiles_or.ok() && !tiles_or.value().empty()) {
1802 const uint16_t tile_id = tiles_or.value().front().id_;
1803 const size_t block_index = static_cast<size_t>(tile_id / 64);
1804 const auto blocks = room.blocks();
1805 if (block_index < blocks.size()) {
1806 sheet_id = blocks[block_index];
1807 resolved_sheet = true;
1808 }
1809 const int tiles_per_row = gfx::kTilesheetWidth / 8;
1810 const int tiles_per_col = gfx::kTilesheetHeight / 8;
1811 const int tiles_per_sheet = tiles_per_row * tiles_per_col;
1812 if (tiles_per_sheet > 0) {
1813 tile_index = static_cast<uint16_t>(tile_id % tiles_per_sheet);
1814 }
1815 }
1816
1817 editor_manager->SwitchToEditor(EditorType::kGraphics, true);
1818 if (auto* editor_set = editor_manager->GetCurrentEditorSet()) {
1819 if (auto* graphics = editor_set->GetGraphicsEditor()) {
1820 if (resolved_sheet) {
1821 graphics->SelectSheet(sheet_id);
1822 graphics->HighlightTile(sheet_id, tile_index,
1823 absl::StrFormat("Object 0x%02X", object.id_));
1824 }
1825 }
1826 }
1827
1830 }
1831}
1832
1833void DungeonEditorV2::SwapRoomInPanel(int old_room_id, int new_room_id) {
1834 // Defer the swap until after the current frame's draw phase completes
1835 // This prevents modifying data structures while ImGui is still using them
1836 if (new_room_id < 0 || new_room_id >= static_cast<int>(rooms_.size())) {
1837 return;
1838 }
1839 pending_swap_.old_room_id = old_room_id;
1840 pending_swap_.new_room_id = new_room_id;
1841 pending_swap_.pending = true;
1842}
1843
1845 if (!pending_swap_.pending) {
1846 return;
1847 }
1848
1849 int old_room_id = pending_swap_.old_room_id;
1850 int new_room_id = pending_swap_.new_room_id;
1851 pending_swap_.pending = false;
1852
1853 // Find the position of old_room in active_rooms_
1854 int swap_index = -1;
1855 for (int i = 0; i < active_rooms_.Size; i++) {
1856 if (active_rooms_[i] == old_room_id) {
1857 swap_index = i;
1858 break;
1859 }
1860 }
1861
1862 if (swap_index < 0) {
1863 // Old room not found in active rooms, just select the new one
1864 OnRoomSelected(new_room_id);
1865 return;
1866 }
1867
1868 // Avoid swapping into an already-open room panel (the per-room maps assume
1869 // room IDs are unique). In this case, just focus/select the existing room.
1870 for (int i = 0; i < active_rooms_.Size; i++) {
1871 if (i != swap_index && active_rooms_[i] == new_room_id) {
1872 OnRoomSelected(new_room_id);
1873 return;
1874 }
1875 }
1876
1877 // Preserve the old panel's stable ImGui window identity by transferring its
1878 // slot id to the new room id.
1879 int slot_id = -1;
1880 if (auto it = room_panel_slot_ids_.find(old_room_id);
1881 it != room_panel_slot_ids_.end()) {
1882 slot_id = it->second;
1883 room_panel_slot_ids_.erase(it);
1884 } else {
1885 slot_id = next_room_panel_slot_id_++;
1886 }
1887 room_panel_slot_ids_[new_room_id] = slot_id;
1888
1889 // Preserve the viewer instance so canvas pan/zoom and UI state don't reset
1890 // when navigating with arrows (swap-in-panel).
1891 room_viewers_.Rename(old_room_id, new_room_id);
1892
1893 // Replace old room with new room in active_rooms_
1894 active_rooms_[swap_index] = new_room_id;
1896
1897 // Unregister old panel
1899 std::string old_card_id = absl::StrFormat("dungeon.room_%d", old_room_id);
1900 const bool old_pinned =
1903
1904 // Register new panel
1905 // Use unified ResourceLabelProvider for room names
1906 std::string new_room_name = absl::StrFormat(
1907 "[%03X] %s", new_room_id, zelda3::GetRoomLabel(new_room_id).c_str());
1908
1909 std::string new_card_id = absl::StrFormat("dungeon.room_%d", new_room_id);
1910
1912 {.card_id = new_card_id,
1913 .display_name = new_room_name,
1914 .window_title = ICON_MD_GRID_ON " " + new_room_name,
1915 .icon = ICON_MD_GRID_ON,
1916 .category = "Dungeon",
1917 .shortcut_hint = "",
1918 .visibility_flag = nullptr,
1919 .priority = 200 + new_room_id});
1920
1921 if (old_pinned) {
1922 dependencies_.window_manager->SetWindowPinned(new_card_id, true);
1923 }
1925 }
1926
1927 // Clean up old room's card and viewer
1928 room_cards_.erase(old_room_id);
1929
1930 // Update current selection
1931 OnRoomSelected(new_room_id, /*request_focus=*/false);
1932}
1933
1936 return;
1937 }
1938
1939 const bool enabled = pending_workflow_mode_.enabled;
1940 const bool show_toast = pending_workflow_mode_.show_toast;
1942 SetWorkbenchWorkflowMode(enabled, show_toast);
1943}
1944
1946 room_viewers_.Touch(room_id);
1947}
1948
1950 // No-op: LruCache handles removal internally via Erase()
1951 (void)room_id;
1952}
1953
1955 if (!viewer) {
1956 return;
1957 }
1958
1959 viewer->SetShowObjectPanelCallback([this]() {
1962 OpenWindow("dungeon.workbench");
1963 return;
1964 }
1966 });
1967 viewer->SetShowSpritePanelCallback([this]() {
1970 OpenWindow("dungeon.workbench");
1971 return;
1972 }
1973 OpenWindow("dungeon.sprite_editor");
1974 });
1975 viewer->SetShowItemPanelCallback([this]() {
1978 OpenWindow("dungeon.workbench");
1979 return;
1980 }
1981 OpenWindow("dungeon.item_editor");
1982 });
1983 viewer->SetShowRoomListCallback([this]() {
1984 OpenWindow(IsWorkbenchWorkflowEnabled() ? "dungeon.workbench"
1986 });
1987 viewer->SetShowRoomMatrixCallback([this]() {
1990 OpenWindow("dungeon.workbench");
1991 return;
1992 }
1994 });
1995 viewer->SetShowEntranceListCallback([this]() {
1998 OpenWindow("dungeon.workbench");
1999 return;
2000 }
2002 });
2003 viewer->SetShowRoomGraphicsCallback([this]() {
2006 OpenWindow("dungeon.workbench");
2007 return;
2008 }
2010 });
2011 viewer->SetShowDoorEditorCallback([this]() {
2014 OpenWindow("dungeon.workbench");
2015 return;
2016 }
2017 OpenWindow("dungeon.door_editor");
2018 });
2019 viewer->SetShowDungeonSettingsCallback([this]() {
2020 if (workbench_panel_) {
2022 }
2023 OpenWindow("dungeon.workbench");
2024 });
2026 [this](int target_room_id, const zelda3::RoomObject& object) {
2027 OpenGraphicsEditorForObject(target_room_id, object);
2028 });
2029 viewer->SetSaveRoomCallback([this](int target_room_id) {
2030 auto status = SaveRoom(target_room_id);
2031 if (!status.ok()) {
2032 LOG_ERROR("DungeonEditorV2", "Apply Room failed: %s",
2033 status.message().data());
2036 absl::StrFormat("Apply Room failed: %s", status.message()),
2038 }
2039 return;
2040 }
2042 dependencies_.toast_manager->Show("Applied room to ROM buffer",
2044 }
2045 });
2046
2049}
2050
2053 (void)room_id;
2054 return GetWorkbenchViewer();
2055 }
2056
2057 // Set eviction predicate to protect active rooms from eviction
2058 room_viewers_.SetEvictionPredicate([this](const int& candidate) {
2059 for (int i = 0; i < active_rooms_.size(); ++i) {
2060 if (active_rooms_[i] == candidate)
2061 return false;
2062 }
2063 return true;
2064 });
2065
2066 if (auto* existing = room_viewers_.Get(room_id)) {
2067 // Viewer already exists - Get() already touched LRU
2068 auto* viewer_ptr = existing->get();
2069 ConfigureViewerRenderContext(viewer_ptr, room_id);
2071
2072 // Update pinned state from manager
2074 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
2075 viewer_ptr->SetPinned(
2077 viewer_ptr->SetPinCallback([this, card_id, room_id](bool pinned) {
2079 dependencies_.window_manager->SetWindowPinned(card_id, pinned);
2080 if (auto* v = GetViewerForRoom(room_id)) {
2081 v->SetPinned(pinned);
2082 }
2083 }
2084 });
2085 }
2086
2087 return viewer_ptr;
2088 }
2089
2090 // Creating a new viewer - Insert will handle LRU and eviction
2091 {
2092 auto viewer = std::make_unique<DungeonCanvasViewer>(rom_);
2093 viewer->SetCompactHeaderMode(false);
2094 viewer->SetRoomDetailsExpanded(true);
2095 DungeonCanvasViewer* viewer_ptr = viewer.get();
2096 viewer->SetRooms(&rooms_);
2097 viewer->SetRenderer(renderer_);
2098 viewer->SetCurrentPaletteGroup(current_palette_group_);
2099 viewer->SetCurrentPaletteId(current_palette_id_);
2100 viewer->SetGameData(game_data_);
2101 ConfigureViewerRenderContext(viewer_ptr, room_id);
2102 ApplyEntranceRenderContext(room_id);
2103
2104 // These hooks must remain correct even when a room panel swaps rooms while
2105 // keeping the same viewer instance (to preserve canvas pan/zoom + UI
2106 // state). Use the viewer's best-effort current room context instead of
2107 // capturing room_id at creation time.
2108 viewer->object_interaction().SetMutationCallback([this, viewer_ptr]() {
2109 const int rid = viewer_ptr ? viewer_ptr->current_room_id() : -1;
2110 if (rid >= 0 && rid < static_cast<int>(rooms_.size())) {
2111 const auto domain =
2113 if (domain == MutationDomain::kTileObjects) {
2114 BeginUndoSnapshot(rid);
2115 } else if (domain == MutationDomain::kCustomCollision) {
2116 BeginCollisionUndoSnapshot(rid);
2117 } else if (domain == MutationDomain::kWaterFill) {
2118 BeginWaterFillUndoSnapshot(rid);
2119 }
2120 }
2121 });
2122
2123 viewer->object_interaction().SetCacheInvalidationCallback([this,
2124 viewer_ptr]() {
2125 const int rid = viewer_ptr ? viewer_ptr->current_room_id() : -1;
2126 if (rid >= 0 && rid < static_cast<int>(rooms_.size())) {
2127 const auto domain =
2129 if (domain == MutationDomain::kTileObjects) {
2130 rooms_[rid].MarkObjectsDirty();
2131 rooms_[rid].RenderRoomGraphics();
2132 // Drag edits invalidate incrementally; finalize once the drag ends
2133 // (TileObjectHandler emits an extra invalidation on release).
2134 const auto mode =
2135 viewer_ptr->object_interaction().mode_manager().GetMode();
2137 FinalizeUndoAction(rid);
2138 }
2139 } else if (domain == MutationDomain::kCustomCollision) {
2140 const auto mode =
2141 viewer_ptr->object_interaction().mode_manager().GetMode();
2142 const auto& st =
2144 if (mode == InteractionMode::PaintCollision && st.is_painting) {
2145 return;
2146 }
2147 FinalizeCollisionUndoAction(rid);
2148 } else if (domain == MutationDomain::kWaterFill) {
2149 const auto mode =
2150 viewer_ptr->object_interaction().mode_manager().GetMode();
2151 const auto& st =
2153 if (mode == InteractionMode::PaintWaterFill && st.is_painting) {
2154 return;
2155 }
2156 FinalizeWaterFillUndoAction(rid);
2157 }
2158 }
2159 });
2160
2161 viewer->object_interaction().SetObjectPlacedCallback(
2162 [this](const zelda3::RoomObject& obj) { HandleObjectPlaced(obj); });
2163
2164 if (dungeon_editor_system_) {
2165 viewer->SetEditorSystem(dungeon_editor_system_.get());
2166 }
2167 viewer->SetRoomNavigationCallback([this](int target_room) {
2168 if (target_room >= 0 && target_room < static_cast<int>(rooms_.size())) {
2169 OnRoomSelected(target_room);
2170 }
2171 });
2172 // Swap callback swaps the room in the current panel instead of opening new
2173 viewer->SetRoomSwapCallback([this](int old_room, int new_room) {
2174 SwapRoomInPanel(old_room, new_room);
2175 });
2176 WireViewerPanelCallbacks(viewer.get());
2177
2178 // Wire up pinning for room panels.
2179 if (dependencies_.window_manager) {
2180 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
2181 viewer->SetPinned(dependencies_.window_manager->IsWindowPinned(card_id));
2182 viewer->SetPinCallback([this, card_id, room_id](bool pinned) {
2183 if (dependencies_.window_manager) {
2184 dependencies_.window_manager->SetWindowPinned(card_id, pinned);
2185 // Sync state back to viewer in all panels showing this room
2186 if (auto* v = GetViewerForRoom(room_id)) {
2187 v->SetPinned(pinned);
2188 }
2189 }
2190 });
2191 }
2192
2193 auto* stored = room_viewers_.Insert(room_id, std::move(viewer));
2194 return stored->get();
2195 }
2196}
2197
2198void DungeonEditorV2::RefreshWorkbenchViewerRuntimeContext(
2199 DungeonCanvasViewer* viewer, int room_id) {
2200 if (!viewer) {
2201 return;
2202 }
2203
2204 if (viewer->rom() != rom_) {
2205 viewer->SetRom(rom_);
2206 }
2207 if (viewer->rooms() != &rooms_) {
2208 viewer->SetRooms(&rooms_);
2209 }
2210 if (viewer->game_data() != game_data_) {
2211 viewer->SetGameData(game_data_);
2212 }
2213 viewer->SetRenderer(renderer_);
2214 viewer->SetCurrentPaletteGroup(current_palette_group_);
2215 viewer->SetCurrentPaletteId(current_palette_id_);
2216 ConfigureViewerRenderContext(viewer, room_id);
2217 viewer->SetEditorSystem(dungeon_editor_system_.get());
2218 viewer->SetMinecartTrackPanel(minecart_track_editor_panel_);
2219 if (viewer->project() != dependencies_.project) {
2220 viewer->SetProject(dependencies_.project);
2221 }
2222}
2223
2224DungeonCanvasViewer* DungeonEditorV2::GetWorkbenchViewer() {
2225 if (!workbench_viewer_) {
2226 workbench_viewer_ = std::make_unique<DungeonCanvasViewer>(rom_);
2227 auto* viewer = workbench_viewer_.get();
2228 viewer->SetCompactHeaderMode(true);
2229 viewer->SetRoomDetailsExpanded(false);
2230 viewer->SetHeaderVisible(false);
2231 viewer->SetHeaderHiddenMetadataHudVisible(false);
2232 viewer->SetRooms(&rooms_);
2233 viewer->SetRenderer(renderer_);
2234 viewer->SetCurrentPaletteGroup(current_palette_group_);
2235 viewer->SetCurrentPaletteId(current_palette_id_);
2236 viewer->SetGameData(game_data_);
2237
2238 // Workbench uses a single viewer; these hooks use the viewer's current room
2239 // context (set at DrawDungeonCanvas start) so room switching stays correct.
2240 viewer->object_interaction().SetMutationCallback([this, viewer]() {
2241 const int rid = viewer ? viewer->current_room_id() : -1;
2242 if (rid >= 0 && rid < static_cast<int>(rooms_.size())) {
2243 const auto domain = viewer->object_interaction().last_mutation_domain();
2244 if (domain == MutationDomain::kTileObjects) {
2245 BeginUndoSnapshot(rid);
2246 } else if (domain == MutationDomain::kCustomCollision) {
2247 BeginCollisionUndoSnapshot(rid);
2248 } else if (domain == MutationDomain::kWaterFill) {
2249 BeginWaterFillUndoSnapshot(rid);
2250 }
2251 }
2252 });
2253 viewer->object_interaction().SetCacheInvalidationCallback([this, viewer]() {
2254 const int rid = viewer ? viewer->current_room_id() : -1;
2255 if (rid >= 0 && rid < static_cast<int>(rooms_.size())) {
2256 const auto domain =
2257 viewer->object_interaction().last_invalidation_domain();
2258 if (domain == MutationDomain::kTileObjects) {
2259 rooms_[rid].MarkObjectsDirty();
2260 rooms_[rid].RenderRoomGraphics();
2261 const auto mode =
2262 viewer->object_interaction().mode_manager().GetMode();
2263 if (mode != InteractionMode::DraggingObjects) {
2264 FinalizeUndoAction(rid);
2265 }
2266 } else if (domain == MutationDomain::kCustomCollision) {
2267 const auto mode =
2268 viewer->object_interaction().mode_manager().GetMode();
2269 const auto& st =
2270 viewer->object_interaction().mode_manager().GetModeState();
2271 if (mode == InteractionMode::PaintCollision && st.is_painting) {
2272 return;
2273 }
2274 FinalizeCollisionUndoAction(rid);
2275 } else if (domain == MutationDomain::kWaterFill) {
2276 const auto mode =
2277 viewer->object_interaction().mode_manager().GetMode();
2278 const auto& st =
2279 viewer->object_interaction().mode_manager().GetModeState();
2280 if (mode == InteractionMode::PaintWaterFill && st.is_painting) {
2281 return;
2282 }
2283 FinalizeWaterFillUndoAction(rid);
2284 }
2285 }
2286 });
2287
2288 viewer->object_interaction().SetObjectPlacedCallback(
2289 [this](const zelda3::RoomObject& obj) { HandleObjectPlaced(obj); });
2290
2291 if (dungeon_editor_system_) {
2292 viewer->SetEditorSystem(dungeon_editor_system_.get());
2293 }
2294
2295 // In workbench mode, arrow navigation swaps the current room without
2296 // changing window identities.
2297 viewer->SetRoomSwapCallback([this](int /*old_room*/, int new_room) {
2298 OnRoomSelected(new_room, /*request_focus=*/false);
2299 });
2300 viewer->SetRoomNavigationCallback([this](int target_room) {
2301 OnRoomSelected(target_room, /*request_focus=*/false);
2302 });
2303
2304 WireViewerPanelCallbacks(viewer);
2305 }
2306
2307 auto* viewer = workbench_viewer_.get();
2308 RefreshWorkbenchViewerRuntimeContext(viewer, current_room_id_);
2309 return viewer;
2310}
2311
2312DungeonCanvasViewer* DungeonEditorV2::GetWorkbenchCompareViewer(int room_id) {
2313 if (!workbench_compare_viewer_) {
2314 workbench_compare_viewer_ = std::make_unique<DungeonCanvasViewer>(rom_);
2315 auto* viewer = workbench_compare_viewer_.get();
2316 viewer->SetCompactHeaderMode(true);
2317 viewer->SetRoomDetailsExpanded(false);
2318 viewer->SetHeaderHiddenMetadataHudVisible(false);
2319 viewer->SetRooms(&rooms_);
2320 viewer->SetRenderer(renderer_);
2321 viewer->SetCurrentPaletteGroup(current_palette_group_);
2322 viewer->SetCurrentPaletteId(current_palette_id_);
2323 viewer->SetGameData(game_data_);
2324
2325 // Compare viewer is read-only by default: no object selection/mutation, but
2326 // still allows canvas pan/zoom.
2327 viewer->SetObjectInteractionEnabled(false);
2328 viewer->SetHeaderReadOnly(true);
2329 viewer->SetHeaderVisible(false);
2330
2331 if (dungeon_editor_system_) {
2332 // Allows consistent rendering paths that depend on the editor system, but
2333 // interaction is still disabled.
2334 viewer->SetEditorSystem(dungeon_editor_system_.get());
2335 }
2336
2337 viewer->SetMinecartTrackPanel(minecart_track_editor_panel_);
2338 viewer->SetProject(dependencies_.project);
2339 }
2340
2341 auto* viewer = workbench_compare_viewer_.get();
2342 RefreshWorkbenchViewerRuntimeContext(viewer, room_id);
2343 viewer->SetObjectInteractionEnabled(false);
2344 viewer->SetHeaderReadOnly(true);
2345 viewer->SetHeaderVisible(false);
2346 return viewer;
2347}
2348
2349void DungeonEditorV2::SyncPanelsToRoom(int room_id) {
2350 // Update object editor card with current viewer
2351 if (object_selector_panel_) {
2352 object_selector_panel_->SetCanvasViewerProvider(
2353 [this]() { return GetViewerForRoom(current_room_id_); });
2354 object_selector_panel_->SetCurrentRoom(room_id);
2355 object_selector_panel_->SetCanvasViewer(GetViewerForRoom(room_id));
2356 }
2357 if (object_editor_content_) {
2358 object_editor_content_->SetCanvasViewerProvider(
2359 [this]() { return GetViewerForRoom(current_room_id_); });
2360 object_editor_content_->SetCurrentRoom(room_id);
2361 object_editor_content_->SetCanvasViewer(GetViewerForRoom(room_id));
2362 }
2363 if (door_editor_panel_) {
2364 door_editor_panel_->SetCanvasViewerProvider(
2365 [this]() { return GetViewerForRoom(current_room_id_); });
2366 door_editor_panel_->SetCurrentRoom(room_id);
2367 door_editor_panel_->SetCanvasViewer(GetViewerForRoom(room_id));
2368 }
2369
2370 // Update sprite and item editor panels with current viewer
2371 if (sprite_editor_panel_) {
2372 sprite_editor_panel_->SetCanvasViewer(GetViewerForRoom(room_id));
2373 }
2374 if (item_editor_panel_) {
2375 item_editor_panel_->SetCanvasViewer(GetViewerForRoom(room_id));
2376 }
2377 if (custom_collision_panel_) {
2378 auto* viewer = GetViewerForRoom(room_id);
2379 custom_collision_panel_->SetCanvasViewer(viewer);
2380 if (viewer) {
2381 custom_collision_panel_->SetInteraction(&viewer->object_interaction());
2382 }
2383 }
2384 if (water_fill_panel_) {
2385 auto* viewer = GetViewerForRoom(room_id);
2386 water_fill_panel_->SetCanvasViewer(viewer);
2387 if (viewer) {
2388 water_fill_panel_->SetInteraction(&viewer->object_interaction());
2389 }
2390 }
2391
2392 if (object_tile_editor_panel_) {
2393 object_tile_editor_panel_->SetCurrentPaletteGroup(current_palette_group_);
2394 }
2395
2396 if (room_tag_editor_panel_) {
2397 room_tag_editor_panel_->SetCurrentRoomId(room_id);
2398 }
2399
2400 if (overlay_manager_panel_) {
2401 auto* viewer = GetViewerForRoom(room_id);
2402 if (viewer) {
2404 overlay_state.show_grid = viewer->mutable_show_grid();
2405 overlay_state.show_object_bounds = viewer->mutable_show_object_bounds();
2406 overlay_state.show_coordinate_overlay =
2407 viewer->mutable_show_coordinate_overlay();
2408 overlay_state.show_room_debug_info =
2409 viewer->mutable_show_room_debug_info();
2410 overlay_state.show_texture_debug = viewer->mutable_show_texture_debug();
2411 overlay_state.show_layer_info = viewer->mutable_show_layer_info();
2412 overlay_state.show_minecart_tracks =
2413 viewer->mutable_show_minecart_tracks();
2414 overlay_state.show_custom_collision =
2415 viewer->mutable_show_custom_collision_overlay();
2416 overlay_state.show_track_collision =
2417 viewer->mutable_show_track_collision_overlay();
2418 overlay_state.show_camera_quadrants =
2419 viewer->mutable_show_camera_quadrant_overlay();
2420 overlay_state.show_minecart_sprites =
2421 viewer->mutable_show_minecart_sprite_overlay();
2422 overlay_state.show_collision_legend =
2423 viewer->mutable_show_track_collision_legend();
2424 overlay_manager_panel_->SetState(overlay_state);
2425 }
2426 }
2427}
2428
2429void DungeonEditorV2::ShowRoomPanel(int room_id) {
2430 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
2431 return;
2432 }
2433
2434 bool already_active = false;
2435 for (int i = 0; i < active_rooms_.Size; ++i) {
2436 if (active_rooms_[i] == room_id) {
2437 already_active = true;
2438 break;
2439 }
2440 }
2441 if (!already_active) {
2442 active_rooms_.push_back(room_id);
2443 room_selector_.set_active_rooms(active_rooms_);
2444 }
2445
2446 std::string card_id = absl::StrFormat("dungeon.room_%d", room_id);
2447
2448 if (dependencies_.window_manager) {
2449 if (!dependencies_.window_manager->GetWindowDescriptor(
2450 dependencies_.window_manager->GetActiveSessionId(), card_id)) {
2451 std::string room_name = absl::StrFormat(
2452 "[%03X] %s", room_id, zelda3::GetRoomLabel(room_id).c_str());
2453 dependencies_.window_manager->RegisterWindow(
2454 {.card_id = card_id,
2455 .display_name = room_name,
2456 .window_title = ICON_MD_GRID_ON " " + room_name,
2457 .icon = ICON_MD_GRID_ON,
2458 .category = "Dungeon",
2459 .shortcut_hint = "",
2460 .visibility_flag = nullptr,
2461 .priority = 200 + room_id});
2462 }
2463 dependencies_.window_manager->OpenWindow(card_id);
2464 }
2465
2466 // Create or update the PanelWindow for this room
2467 if (room_cards_.find(room_id) == room_cards_.end()) {
2468 std::string base_name = absl::StrFormat(
2469 "[%03X] %s", room_id, zelda3::GetRoomLabel(room_id).c_str());
2470 const int slot_id = GetOrCreateRoomPanelSlotId(room_id);
2471 std::string card_name_str = absl::StrFormat(
2472 "%s###RoomPanelSlot%d", MakePanelTitle(base_name).c_str(), slot_id);
2473
2474 auto card = std::make_shared<gui::PanelWindow>(card_name_str.c_str(),
2476 card->SetDefaultSize(620, 700);
2477 room_cards_[room_id] = card;
2478 }
2479}
2480
2481} // namespace yaze::editor
void Publish(const T &event)
Definition event_bus.h:35
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:28
bool is_loaded() const
Definition rom.h:144
static Flags & get()
Definition features.h:119
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void SetInteraction(DungeonObjectInteraction *interaction)
void SetCanvasViewer(DungeonCanvasViewer *viewer)
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 SetCurrentPaletteGroup(const gfx::PaletteGroup &group)
void SetShowRoomListCallback(std::function< void()> callback)
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 SetSaveRoomCallback(std::function< void(int)> callback)
void SetShowDungeonSettingsCallback(std::function< void()> callback)
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_
std::unique_ptr< CustomCollisionPanel > owned_custom_collision_panel_
class CustomCollisionPanel * custom_collision_panel_
void ToggleWorkbenchWorkflowMode(bool show_toast=true)
void ContributeStatus(StatusBar *status_bar) override
void OpenGraphicsEditorForObject(int room_id, const zelda3::RoomObject &object)
class ItemEditorPanel * item_editor_panel_
std::unique_ptr< RoomTagEditorPanel > owned_room_tag_editor_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
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_
void OnRoomSelected(int room_id, bool request_focus=true)
PendingCollisionUndo pending_collision_undo_
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)
void WireViewerPanelCallbacks(DungeonCanvasViewer *viewer)
std::unique_ptr< DungeonCanvasViewer > workbench_compare_viewer_
PendingWaterFillUndo pending_water_fill_undo_
std::unique_ptr< MinecartTrackEditorPanel > owned_minecart_track_editor_panel_
gui::PaletteEditorWidget palette_editor_
std::unique_ptr< ObjectEditorContent > owned_object_editor_content_
std::unique_ptr< DoorEditorContent > owned_door_editor_panel_
static bool IsValidRoomId(int room_id)
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)
absl::Status Update() override
class WaterFillPanel * water_fill_panel_
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)
ObjectEditorContent * object_editor_content_
static constexpr const char * kRoomMatrixId
static constexpr const char * kRoomSelectorId
PaletteEditorContent * palette_editor_panel_
std::unique_ptr< DungeonCanvasViewer > workbench_viewer_
std::unique_ptr< WaterFillPanel > owned_water_fill_panel_
static constexpr const char * kPaletteEditorId
PendingWorkflowMode pending_workflow_mode_
void InvalidateDungeonPaletteUsers(gui::DungeonPaletteChange change)
void SetCustomObjectsFolder(const std::string &folder)
void SetProject(project::YazeProject *project)
void SetTileEditorPanel(ObjectTileEditorPanel *panel)
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 SetProject(const project::YazeProject *project)
void set_active_rooms(const ImVector< int > &rooms)
zelda3::Room * GetIfMaterialized(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 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 SetOnInspectorSideChanged(std::function< void(bool)> cb)
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)
The EditorManager controls the main editor window and manages the various editor classes.
UndoManager undo_manager_
Definition editor.h:334
zelda3::GameData * game_data() const
Definition editor.h:320
EditorDependencies dependencies_
Definition editor.h:333
EditorType type_
Definition editor.h:332
InteractionMode GetMode() const
Get current interaction mode.
ModeState & GetModeState()
Get mutable reference to mode state.
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void SetProject(project::YazeProject *project)
void SetRoomNavigationCallback(RoomNavigationCallback callback)
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void SetPlacementError(const std::string &message)
void SetCanvasViewer(DungeonCanvasViewer *viewer)
void SetRooms(DungeonRoomStore *rooms)
void SetGameData(zelda3::GameData *game_data)
void SetCurrentPaletteGroup(const gfx::PaletteGroup &group)
void SetCurrentPaletteGroup(const gfx::PaletteGroup &group)
void SetCurrentPaletteGroup(const gfx::PaletteGroup &group)
Set the current palette group for graphics rendering.
void SetRooms(DungeonRoomStore *rooms)
void SetCanvasViewer(DungeonCanvasViewer *viewer)
A session-aware status bar displayed at the bottom of the application.
Definition status_bar.h:54
void SetCustomSegment(const std::string &key, const std::string &value)
Set a custom segment with key-value pair.
void SetEditorMode(const std::string &mode)
Set the current editor mode or tool.
void Show(const std::string &message, ToastType type=ToastType::kInfo, float ttl_seconds=3.0f)
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)
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 ShowAllWindowsInCategory(size_t session_id, const std::string &category)
void UnregisterWindow(size_t session_id, const std::string &base_window_id)
void RegisterPanelAlias(const std::string &legacy_base_id, const std::string &canonical_base_id)
Register a legacy panel ID alias that resolves to a canonical ID.
static std::shared_ptr< MesenSocketClient > & GetClient()
void ProcessTextureQueue(IRenderer *renderer)
Definition arena.cc:116
static Arena & Get()
Definition arena.cc:21
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()
void AddObjectFile(int object_id, const std::string &filename)
void Initialize(const std::string &custom_objects_folder)
static ObjectDimensionTable & Get()
void set_water_fill_sram_bit_mask(uint8_t mask)
Definition room.h:573
bool HasUnsavedChanges() const
Definition room.h:588
void MarkGraphicsDirty()
Definition room.h:449
bool IsLoaded() const
Definition room.h:871
void ClearWaterFillZone()
Definition room.h:560
void ClearWaterFillDirty()
Definition room.h:581
int ResolveDungeonPaletteId() const
Definition room.cc:724
void SetGameData(GameData *data)
Definition room.h:963
void SetWaterFillTile(int x, int y, bool filled)
Definition room.h:537
#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_TUNE
Definition icons.h:2022
#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:103
#define LOG_ERROR(category, format,...)
Definition log.h:109
#define LOG_WARN(category, format,...)
Definition log.h:107
#define LOG_INFO(category, format,...)
Definition log.h:105
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
const AgentUITheme & GetTheme()
bool IsTransientDungeonRoomWindowId(const std::string &card_id)
constexpr const char * kStandalone
constexpr const char * kWorkbench
Editors are the view controllers for the application.
RoomSelectionIntent
Intent for room selection in the dungeon editor.
absl::StatusOr< PaletteGroup > CreatePaletteGroupFromLargePalette(SnesPalette &palette, int num_colors)
Create a PaletteGroup by dividing a large palette into sub-palettes.
constexpr int kTilesheetHeight
Definition snes_tile.h:17
constexpr int kTilesheetWidth
Definition snes_tile.h:16
std::string MakePanelTitle(const std::string &title)
Room LoadRoomHeaderFromRom(Rom *rom, int room_id)
Definition room.cc:543
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)
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
project::YazeProject * project
Definition editor.h:173
GlobalEditorContext * global_context
Definition editor.h:175
gfx::IRenderer * renderer
Definition editor.h:191
WorkspaceWindowManager * window_manager
Definition editor.h:181
static JumpToRoomRequestEvent Create(int room, size_t session=0)
Optional behavior for an interactive status bar segment.
Definition status_bar.h:27
std::function< void()> on_click
Definition status_bar.h:28
Represents a group of palettes.
DungeonRenderPaletteSource source
static constexpr float kStatusBarHeight
Definition ui_config.h:21
std::unordered_map< int, std::vector< std::string > > custom_object_files
Definition project.h:197
std::string custom_objects_folder
Definition project.h:192
std::string GetAbsolutePath(const std::string &relative_path) const
Definition project.cc:1486
std::string symbols_filename
Definition project.h:190
gfx::PaletteGroupMap palette_groups
Definition game_data.h:92
PitDamageTable pit_damage_table
Definition game_data.h:108