yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_editor_v2_undo.cc
Go to the documentation of this file.
1// Undo/redo, clipboard, and room-state restore implementation for
2// DungeonEditorV2. Split out of dungeon_editor_v2.cc to keep that
3// translation unit within the editor source-size guardrail. Class
4// declaration lives in dungeon_editor_v2.h.
5
6#include <algorithm>
7#include <cctype>
8#include <cstdio>
9#include <exception>
10#include <iterator>
11#include <memory>
12#include <string>
13#include <unordered_set>
14#include <utility>
15#include <vector>
16#include "absl/status/status.h"
17#include "absl/strings/str_format.h"
18#include "absl/types/span.h"
55#include "app/gui/core/icons.h"
57#include "core/features.h"
58#include "core/project.h"
59#include "dungeon_editor_v2.h"
60#include "imgui/imgui.h"
61#include "rom/snes.h"
62#include "util/log.h"
63#include "util/macro.h"
69#include "zelda3/dungeon/room.h"
73
74namespace yaze::editor {
75
76namespace {
77
78class RoomGraphicsSheetAction final : public UndoAction {
79 public:
80 using Apply = std::function<absl::Status(uint8_t, uint8_t)>;
81 RoomGraphicsSheetAction(int group, int slot, uint8_t before, uint8_t after,
82 Apply apply)
83 : group_(group),
84 slot_(slot),
85 before_(before),
86 after_(after),
87 apply_(std::move(apply)) {}
88 absl::Status Undo() override { return apply_(after_, before_); }
89 absl::Status Redo() override { return apply_(before_, after_); }
90 std::string Description() const override {
91 return absl::StrFormat("Room graphics group %02X slot %d", group_, slot_);
92 }
93 size_t MemoryUsage() const override { return sizeof(*this); }
94
95 private:
96 int group_;
97 int slot_;
98 uint8_t before_;
99 uint8_t after_;
101};
102
103} // namespace
104
105absl::Status DungeonEditorV2::EditRoomGraphicsSheet(int room_id, int slot,
106 int sheet_id) {
107 if (!game_data_ || !rom_ || !rom_->is_loaded()) {
108 return absl::FailedPreconditionError("No loaded graphics session");
109 }
110 auto* room = rooms_.GetIfMaterialized(room_id);
111 if (!room || slot < 3 || slot > 6 || sheet_id < 0 ||
113 return absl::InvalidArgumentError("Choose a roomset slot (3-6) and sheet");
114 }
115 if (sheet_id != 0 && (!game_data_->sheet_store.HasSheet(sheet_id) ||
118 return absl::InvalidArgumentError(
119 "Room backgrounds require a loaded 3bpp sheet");
120 }
121 const int group = room->blockset();
122 if (group >= static_cast<int>(game_data_->room_blockset_ids.size())) {
123 return absl::OutOfRangeError("Room graphics group is unavailable");
124 }
125 const uint8_t before = game_data_->room_blockset_ids[group][slot - 3];
126 const auto after = static_cast<uint8_t>(sheet_id);
127 if (before == after) {
128 return absl::OkStatus();
129 }
131 auto apply = [this, owner = game_data_, source_rom = rom_, group, slot](
132 uint8_t expected, uint8_t value) -> absl::Status {
133 if (game_data_ != owner || rom_ != source_rom) {
134 return absl::FailedPreconditionError(
135 "Graphics assignment belongs to another session");
136 }
137 auto& assigned = game_data_->room_blockset_ids[group][slot - 3];
138 if (assigned != expected) {
139 return absl::FailedPreconditionError(
140 "Graphics assignment changed elsewhere; Undo was not applied");
141 }
142 assigned = value;
143 rooms_.ForEachMaterialized([group](int, zelda3::Room& affected) {
144 if (affected.blockset() == group) {
145 affected.MarkGraphicsDirty();
146 }
147 });
148 rom_->set_dirty(true);
149 return absl::OkStatus();
150 };
151 RETURN_IF_ERROR(apply(before, after));
152 undo_manager_.Push(std::make_unique<RoomGraphicsSheetAction>(
153 group, slot, before, after, std::move(apply)));
154 return absl::OkStatus();
155}
156
158 // Closing history alone leaves a handler's drag/paint gesture running, so its
159 // next movement could bypass the before-snapshot. End gestures first.
160 room_viewers_.ForEach([](int, std::unique_ptr<DungeonCanvasViewer>& viewer) {
161 if (viewer) {
162 viewer->CancelMinecartGesture();
163 viewer->object_interaction().HandleMouseRelease();
164 }
165 });
166 for (auto* viewer :
168 if (viewer) {
169 viewer->CancelMinecartGesture();
170 viewer->object_interaction().HandleMouseRelease();
171 }
172 }
173 if (pending_undo_.room_id >= 0) {
175 }
178 }
181 }
184}
185
186absl::Status DungeonEditorV2::Undo() {
188 const std::string description = undo_manager_.GetUndoDescription();
190 auto status = undo_manager_.Undo();
191 if (status.ok()) {
193 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
194 viewer->TriggerChangePing();
195 }
196 }
199 description.empty() ? "Undid last dungeon edit"
200 : absl::StrFormat("Undid: %s", description),
201 ToastType::kInfo, 2.0f);
202 }
203 }
205 return status;
206}
207
208absl::Status DungeonEditorV2::Redo() {
210 const std::string description = undo_manager_.GetRedoDescription();
212 auto status = undo_manager_.Redo();
213 if (status.ok()) {
215 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
216 viewer->TriggerChangePing();
217 }
218 }
221 description.empty() ? "Redid last dungeon edit"
222 : absl::StrFormat("Redid: %s", description),
223 ToastType::kInfo, 2.0f);
224 }
225 }
227 return status;
228}
229
230absl::Status DungeonEditorV2::Cut() {
231 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
232 RETURN_IF_ERROR(viewer->object_interaction().HandleCopySelected());
233 return viewer->object_interaction().HandleDeleteSelected();
234 }
235 return absl::OkStatus();
236}
237
238absl::Status DungeonEditorV2::Copy() {
239 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
240 return viewer->object_interaction().HandleCopySelected();
241 }
242 return absl::OkStatus();
243}
244
246 if (auto* viewer = GetViewerForRoom(current_room_id_)) {
247 return viewer->object_interaction().HandlePasteObjects();
248 }
249 return absl::OkStatus();
250}
251
252namespace {
253
255 return domain == MutationDomain::kDoors ||
257}
258
260 return static_cast<size_t>(domain) -
261 static_cast<size_t>(MutationDomain::kDoors);
262}
263
265 const DungeonEntitySnapshot& b) {
266 return a.sprites == b.sprites && a.doors.size() == b.doors.size() &&
267 std::equal(a.doors.begin(), a.doors.end(), b.doors.begin(),
268 [](const auto& x, const auto& y) {
269 return x.position == y.position && x.type == y.type &&
270 x.direction == y.direction && x.byte1 == y.byte1 &&
271 x.byte2 == y.byte2;
272 }) &&
273 a.items.size() == b.items.size() &&
274 std::equal(a.items.begin(), a.items.end(), b.items.begin(),
275 [](const auto& x, const auto& y) {
276 return x.position == y.position && x.item == y.item;
277 });
278}
279
280std::vector<SelectedEntity> ValidEntitySelection(
281 const zelda3::Room& room, const std::vector<SelectedEntity>& selected) {
282 std::vector<SelectedEntity> valid;
283 for (const auto entity : selected) {
284 if ((entity.type == EntityType::Door &&
285 entity.index < room.GetDoors().size()) ||
286 (entity.type == EntityType::Sprite &&
287 entity.index < room.GetSprites().size()) ||
288 (entity.type == EntityType::Item &&
289 entity.index < room.GetPotItems().size())) {
290 valid.push_back(entity);
291 }
292 }
293 return valid;
294}
295
296} // namespace
297
302 [this](int id) { return ExportRoomDocument(id); },
303 [this](int target, int source, const std::string& json,
304 const DungeonRoomTransferOptions& options) {
305 return PreviewRoomTransfer(target, source, json, options);
306 },
307 [this](const DungeonRoomTransferPlan& plan) {
308 return ApplyRoomTransfer(plan);
309 });
311 [this](int room_id, size_t index) {
312 const auto* room = rooms_.GetIfLoaded(room_id);
313 if (!room || room->rom() != rom_ || index >= room->GetDoors().size()) {
314 return;
315 }
316 const auto door = room->GetDoors()[index];
317 OnRoomSelected(room_id, true);
318 if (auto* target = GetViewerForRoom(room_id)) {
319 // Workbench navigation draws the new room on the following frame. Bind
320 // its editing context now so the exact return selection survives that draw.
321 target->object_interaction().SetCurrentRoom(&rooms_, room_id);
322 target->object_interaction().ClearSelection();
323 target->object_interaction().SelectEntity(EntityType::Door, index);
324 const auto [x, y, width, height] = door.GetEditorBounds();
325 target->ScrollToTile((x + width / 2) / 8, (y + height / 2) / 8);
326 target->TriggerCanvasPingRect(x, y, width, height);
327 }
328 });
330 [this](const DungeonConnectionRequest& request) {
331 return PreviewDoorConnection(request);
332 },
333 [this](const DungeonConnectionPlan& plan) {
334 return ApplyDoorConnection(plan);
335 });
337 [this](const DungeonSelectionEditPlan& plan, bool continuous) {
338 return CommitSelectionEdit(plan, continuous);
339 },
340 [this]() { FinalizeSelectionUndoAction(); },
341 [this](const absl::Status& status) {
344 absl::StrFormat("Edit not applied: %s", status.message()),
345 ToastType::kWarning, 4.0f);
346 }
347 });
349 [this](int room_id, const RoomMetadataEdit& edit) {
350 return EditRoomMetadata(room_id, edit);
351 });
353 [this](const std::vector<RoomMetadataRequest>& requests) {
354 return EditRoomMetadataBatch(requests);
355 });
356 viewer->SetChestEditCallback(
357 [this](int room_id, size_t index, uint8_t item_id, bool big_chest) {
358 return EditChest(room_id, index, item_id, big_chest);
359 });
360 viewer->SetChestDeleteCallback([this](int room_id, size_t index) {
361 return DeleteChest(room_id, index);
362 });
363 viewer->object_interaction()
365 .tile_handler()
367 [this](int room_id, const std::vector<zelda3::RoomObject>& objects,
368 const std::vector<chest_data>& chests) {
369 return PreflightObjectMutation(room_id, objects, chests);
370 });
371 viewer->object_interaction()
373 .tile_handler()
375 [this]() { FinalizePendingUndoActions(); });
376 viewer->object_interaction()
378 .tile_handler()
379 .SetMutationErrorCallback([this](const absl::Status& status) {
382 absl::StrFormat("Edit not applied: %s", status.message()),
383 ToastType::kWarning, 4.0f);
384 }
385 });
386 auto& door_handler =
388 door_handler.SetDoorMutationPreflight(
389 [this](int id, const std::vector<zelda3::Room::Door>& doors) {
390 return PreflightDoorMutation(id, doors);
391 });
392 door_handler.SetMutationErrorCallback([this](const absl::Status& status) {
395 absl::StrFormat("Edit not applied: %s", status.message()),
396 ToastType::kWarning, 4.0f);
397 }
398 });
399 auto& sprite_handler =
401 sprite_handler.SetSpriteMutationPreflight(
402 [this](int id, const std::vector<zelda3::Sprite>& sprites) {
403 return PreflightSpriteMutation(id, sprites);
404 });
405 sprite_handler.SetMutationErrorCallback([this](const absl::Status& status) {
408 absl::StrFormat("Edit not applied: %s", status.message()),
409 ToastType::kWarning, 4.0f);
410 }
411 });
412 auto& item_handler =
414 item_handler.SetItemPlacementPreflight(
415 [this](int id, const std::vector<zelda3::PotItem>& items) {
416 return PreflightPotItemPlacement(id, items);
417 });
418 item_handler.SetMutationErrorCallback([this](const absl::Status& status) {
421 absl::StrFormat("Edit not applied: %s", status.message()),
422 ToastType::kWarning, 4.0f);
423 }
424 });
425 // The interaction context is the mutation source of truth. A retained viewer
426 // can change rooms, so do not capture the room ID from its creation time.
427 viewer->object_interaction().SetMutationCallback([this, viewer]() {
429 const auto* ctx = viewer->object_interaction()
431 .tile_handler()
432 .context();
433 const int rid = ctx ? ctx->current_room_id : -1;
434 if (!IsValidRoomId(rid)) {
435 return;
436 }
437 const auto domain = ctx->last_mutation_domain;
438 if (domain == MutationDomain::kTileObjects) {
440 } else if (IsEntityMutation(domain)) {
441 BeginEntityUndoSnapshot(rid, domain);
442 } else if (domain == MutationDomain::kCustomCollision) {
444 } else if (domain == MutationDomain::kWaterFill) {
446 }
447 });
448 viewer->object_interaction().SetCacheInvalidationCallback([this, viewer]() {
449 const auto* ctx = viewer->object_interaction()
451 .tile_handler()
452 .context();
453 const int rid = ctx ? ctx->current_room_id : -1;
454 if (!IsValidRoomId(rid)) {
455 return;
456 }
457 auto& interaction = viewer->object_interaction();
458 const auto domain = ctx->last_invalidation_domain;
459 const auto mode = interaction.mode_manager().GetMode();
460 if (domain == MutationDomain::kTileObjects) {
461 rooms_[rid].MarkObjectsDirty();
462 rooms_[rid].RenderRoomGraphics();
465 }
466 } else if (IsEntityMutation(domain)) {
467 // Entity drags publish their invalidation only at release. Property,
468 // placement and keyboard edits each publish a single completion.
469 // Doors, pot indicators and sprite key drops all contribute pixels to
470 // the object buffers. Save dirtiness alone does not refresh those pixels.
471 auto& room = rooms_[rid];
472 room.MarkObjectsDirty();
473 if (room.rom() == rom_ && rom_ && rom_->is_loaded()) {
474 room.RenderRoomGraphics();
475 }
476 FinalizeEntityUndoAction(rid, domain);
477 } else if (domain == MutationDomain::kCustomCollision) {
478 if (!(mode == InteractionMode::PaintCollision &&
479 interaction.mode_manager().GetModeState().is_painting)) {
481 }
482 } else if (domain == MutationDomain::kWaterFill) {
483 if (!(mode == InteractionMode::PaintWaterFill &&
484 interaction.mode_manager().GetModeState().is_painting)) {
486 }
487 }
488 });
489}
490
492 int room_id, MutationDomain domain) {
494 const auto& room = rooms_[room_id];
495 if (domain == MutationDomain::kDoors) {
496 state.doors = room.GetDoors();
497 } else if (domain == MutationDomain::kSprites) {
498 for (const auto& sprite : room.GetSprites()) {
499 state.sprites.push_back({sprite.id(), sprite.x(), sprite.y(),
500 sprite.subtype(), sprite.layer(),
501 sprite.key_drop(), sprite.deleted()});
502 }
503 } else if (domain == MutationDomain::kItems) {
504 state.items = room.GetPotItems();
505 }
506 if (auto* viewer = GetViewerForRoom(room_id);
507 viewer &&
508 (!IsWorkbenchWorkflowEnabled() || viewer->object_interaction()
509 .entity_coordinator()
510 .tile_handler()
511 .context()
512 ->current_room_id == room_id)) {
513 auto& interaction = viewer->object_interaction();
514 state.entities = interaction.entity_coordinator().GetSelectedEntities();
515 if (state.entities.empty() && interaction.HasEntitySelection()) {
516 state.entities.push_back(interaction.GetSelectedEntity());
517 }
518 state.entities = ValidEntitySelection(room, state.entities);
519 state.objects = interaction.GetSelectedObjectIndices();
520 }
521 return state;
522}
523
525 MutationDomain domain) {
526 if (!IsValidRoomId(room_id) || !IsEntityMutation(domain)) {
527 return;
528 }
529 auto& pending = pending_entity_undo_[EntityDomainIndex(domain)];
530 if (pending.room_id >= 0) {
531 FinalizeEntityUndoAction(pending.room_id, domain);
532 }
533 pending.room_id = room_id;
534 pending.before = CaptureRoomEntities(room_id, domain);
535}
536
538 MutationDomain domain) {
539 if (!IsValidRoomId(room_id) || !IsEntityMutation(domain)) {
540 return;
541 }
542 auto& pending = pending_entity_undo_[EntityDomainIndex(domain)];
543 if (pending.room_id != room_id) {
544 return;
545 }
546 auto after = CaptureRoomEntities(room_id, domain);
547 if (SameEntityData(pending.before, after)) {
548 pending = {};
549 return;
550 }
551 // Deletion shifts vector indices. Do not let an old selected index silently
552 // pick the next entity; undo restores the original selection explicitly.
553 const bool removed = after.doors.size() < pending.before.doors.size() ||
554 after.sprites.size() < pending.before.sprites.size() ||
555 after.items.size() < pending.before.items.size();
556 if (removed) {
557 const auto type = domain == MutationDomain::kDoors ? EntityType::Door
558 : domain == MutationDomain::kSprites ? EntityType::Sprite
559 : EntityType::Item;
560 std::erase_if(after.entities,
561 [type](const auto& entity) { return entity.type == type; });
562 if (auto* viewer = GetViewerForRoom(room_id);
563 viewer && viewer->object_interaction()
564 .entity_coordinator()
565 .tile_handler()
566 .context()
567 ->current_room_id == room_id) {
568 viewer->object_interaction().entity_coordinator().SetSelectedEntities(
569 after.entities);
570 }
571 }
572 undo_manager_.Push(std::make_unique<DungeonEntitiesAction>(
573 room_id, domain, std::move(pending.before), std::move(after),
574 [this](int rid, MutationDomain restored_domain,
575 const DungeonEntitySnapshot& snapshot) {
576 return RestoreRoomEntities(rid, restored_domain, snapshot);
577 }));
578 pending = {};
579}
580
582 for (const auto domain : {MutationDomain::kDoors, MutationDomain::kSprites,
584 const auto& pending = pending_entity_undo_[EntityDomainIndex(domain)];
585 if (pending.room_id >= 0) {
586 FinalizeEntityUndoAction(pending.room_id, domain);
587 }
588 }
589}
590
592 int room_id, MutationDomain domain, const DungeonEntitySnapshot& snapshot) {
593 if (!IsValidRoomId(room_id) || !IsEntityMutation(domain)) {
594 return absl::InvalidArgumentError("Invalid dungeon entity undo target");
595 }
596 auto* room = rooms_.GetIfMaterialized(room_id);
597 if (!room) {
598 return absl::FailedPreconditionError(
599 "Dungeon entity undo room is not loaded");
600 }
601 if (domain == MutationDomain::kDoors) {
602 room->GetDoors() = snapshot.doors;
603 room->MarkObjectStreamDirty();
604 } else if (domain == MutationDomain::kSprites) {
605 std::vector<zelda3::Sprite> restored;
606 restored.reserve(snapshot.sprites.size());
607 for (const auto& sprite : snapshot.sprites) {
608 restored.emplace_back(sprite.id, sprite.x, sprite.y, sprite.subtype,
609 sprite.layer);
610 restored.back().set_key_drop(sprite.key_drop);
611 restored.back().set_deleted(sprite.deleted);
612 }
613 room->GetSprites() = std::move(restored);
614 room->MarkSpritesDirty();
615 } else {
616 room->GetPotItems() = snapshot.items;
617 room->MarkPotItemsDirty();
618 }
619 room->MarkObjectsDirty();
620 if (room->rom() == rom_ && rom_ && rom_->is_loaded()) {
621 room->RenderRoomGraphics();
622 }
623 // An offscreen restore must not flash the currently displayed room.
625 if (auto* viewer = GetViewerForRoom(room_id)) {
626 if (viewer->object_interaction()
627 .entity_coordinator()
628 .tile_handler()
629 .context()
630 ->current_room_id == room_id) {
631 auto& interaction = viewer->object_interaction();
632 interaction.CancelPlacement();
633 std::vector<size_t> objects;
634 for (size_t index : snapshot.objects) {
635 if (index < room->GetTileObjects().size()) {
636 objects.push_back(index);
637 }
638 }
639 interaction.SetSelectedObjects(objects);
640 interaction.entity_coordinator().SetSelectedEntities(
641 ValidEntitySelection(*room, snapshot.entities));
642 viewer->TriggerChangePing();
644 }
645 }
646 return absl::OkStatus();
647}
648
650 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
651 return;
652
653 // Detect leaked undo snapshots (double-Begin without Finalize).
654 if (has_pending_undo_) {
655 LOG_ERROR("DungeonEditor",
656 "BeginUndoSnapshot called twice without FinalizeUndoAction. "
657 "Previous snapshot for room %d is being leaked. Finalizing now.",
659 // Auto-finalize the leaked snapshot to prevent silent state loss.
660 if (pending_undo_.room_id >= 0) {
662 }
663 }
664
665 pending_undo_.room_id = room_id;
666 pending_undo_.before_objects = rooms_[room_id].GetTileObjects();
667 pending_undo_.before_chests = rooms_[room_id].GetChests();
669 if (auto* viewer = GetViewerForRoom(room_id);
670 viewer &&
671 (!IsWorkbenchWorkflowEnabled() || viewer->current_room_id() == room_id)) {
673 viewer->object_interaction().GetSelectedObjectIndices();
674 }
675 has_pending_undo_ = true;
676}
677
679 if (pending_undo_.room_id < 0 || pending_undo_.room_id != room_id)
680 return;
681 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
682 return;
683
684 auto after_objects = rooms_[room_id].GetTileObjects();
685 std::vector<size_t> after_selection;
686 if (auto* viewer = GetViewerForRoom(room_id);
687 viewer &&
688 (!IsWorkbenchWorkflowEnabled() || viewer->current_room_id() == room_id)) {
689 after_selection = viewer->object_interaction().GetSelectedObjectIndices();
690 }
691
692 auto action = std::make_unique<DungeonObjectsAction>(
693 room_id, std::move(pending_undo_.before_objects),
694 std::move(pending_undo_.before_selection), std::move(after_objects),
695 std::move(after_selection), std::move(pending_undo_.before_chests),
696 rooms_[room_id].GetChests(),
697 [this](int rid, const std::vector<zelda3::RoomObject>& objects,
698 const std::vector<size_t>& selected_indices,
699 const std::vector<chest_data>& chests) {
700 return RestoreRoomObjects(rid, objects, selected_indices, chests);
701 });
702 undo_manager_.Push(std::move(action));
705 }
706
711 has_pending_undo_ = false;
712}
713
715 int room_id, const std::vector<zelda3::RoomObject>& objects,
716 const std::vector<size_t>& selected_indices,
717 const std::vector<chest_data>& chests) {
718 auto* loaded_room = rooms_.GetIfLoaded(room_id);
719 if (!loaded_room || loaded_room->rom() != rom_) {
720 return absl::FailedPreconditionError("Room objects are not loaded");
721 }
722 auto& room = *loaded_room;
723 if (room.GetChests().size() != chests.size() ||
724 !std::equal(
725 chests.begin(), chests.end(), room.GetChests().begin(),
726 [](auto a, auto b) { return a.id == b.id && a.size == b.size; })) {
727 room.GetChests() = chests;
728 room.MarkChestsDirty();
729 }
730 const auto previous_objects = room.GetTileObjects();
731 room.SetTileObjects(objects);
732 room.RenderRoomGraphics();
733 if (auto* viewer = GetViewerForRoom(room_id);
734 viewer &&
735 (!IsWorkbenchWorkflowEnabled() || viewer->current_room_id() == room_id)) {
736 std::vector<size_t> valid_selection;
737 for (size_t index : selected_indices) {
738 if (index < objects.size()) {
739 valid_selection.push_back(index);
740 }
741 }
742 viewer->object_interaction().SetSelectedObjects(valid_selection);
743 viewer->TriggerObjectChangePing(previous_objects, objects);
745 }
748 }
750 return absl::OkStatus();
751}
752
754 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
755 return;
756
759 }
760
762 pending_collision_undo_.before = rooms_[room_id].custom_collision();
763}
764
767 pending_collision_undo_.room_id != room_id) {
768 return;
769 }
770 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
771 return;
772
773 auto after = rooms_[room_id].custom_collision();
774 if (pending_collision_undo_.before == after) {
777 return;
778 }
779
780 auto action = std::make_unique<DungeonCustomCollisionAction>(
781 room_id, std::move(pending_collision_undo_.before), std::move(after),
782 [this](int rid, const zelda3::CustomCollisionMap& map) {
783 RestoreRoomCustomCollision(rid, map);
784 });
785 undo_manager_.Push(std::move(action));
788 }
789
792}
793
795 int room_id, const zelda3::CustomCollisionMap& map) {
796 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
797 return;
798
799 auto& room = rooms_[room_id];
800 room.custom_collision() = map;
801 room.MarkCustomCollisionDirty();
804 }
805}
806
807namespace {
808
810 return map.has_data || map.HasOverrides();
811}
812
814 const zelda3::CustomCollisionMap& rhs) {
815 return lhs == rhs;
816}
817
819 zelda3::Room* room = nullptr;
821 bool dirty = false;
822};
823
825 public:
826 explicit CollisionBatchRollback(std::vector<CollisionRollbackEntry> entries)
827 : entries_(std::move(entries)) {}
828
830 if (committed_) {
831 return;
832 }
833 for (const auto& entry : entries_) {
834 entry.room->custom_collision() = entry.map;
835 if (entry.dirty) {
836 entry.room->MarkCustomCollisionDirty();
837 } else {
838 entry.room->ClearCustomCollisionDirty();
839 }
840 }
841 }
842
843 void Commit() { committed_ = true; }
844
845 private:
846 std::vector<CollisionRollbackEntry> entries_;
847 bool committed_ = false;
848};
849
850} // namespace
851
853 int room_id, const zelda3::CustomCollisionMap& expected,
854 const MinecartCollisionStamp& stamp) {
855 auto* room = rooms_.GetIfMaterialized(room_id);
856 if (!room || !rom_ ||
858 return absl::FailedPreconditionError(
859 "Load a room with expanded collision support");
860 }
861 if (!CollisionMapsEqual(room->custom_collision(), expected)) {
862 return absl::FailedPreconditionError(
863 "Collision changed; preview the stamp again");
864 }
865 ASSIGN_OR_RETURN(auto after, PreviewMinecartCollisionStamp(expected, stamp));
866 if (CollisionMapsEqual(expected, after))
867 return absl::OkStatus();
868 // Finalizing another tool may invalidate overlays, so keep value snapshots.
869 const auto before = expected;
871 auto action = std::make_unique<DungeonCustomCollisionAction>(
872 room_id, before, after,
873 [this](int rid, const zelda3::CustomCollisionMap& map) {
875 });
876 CollisionBatchRollback rollback(
877 {{room, before, room->custom_collision_dirty()}});
878 room->custom_collision() = after;
879 room->MarkCustomCollisionDirty();
880 try {
881 undo_manager_.Push(std::move(action));
882 } catch (const std::exception& error) {
883 return absl::InternalError(
884 absl::StrFormat("Could not record rail undo: %s", error.what()));
885 }
886 rollback.Commit();
889 return absl::OkStatus();
890}
891
893 const std::vector<zelda3::TrackCollisionResult>& preview,
894 const zelda3::GeneratorOptions& options) {
895 if (preview.empty()) {
896 return absl::InvalidArgumentError("Minecart collision preview is empty");
897 }
898
899 std::unordered_set<int> seen_room_ids;
900 std::vector<DungeonCustomCollisionSnapshot> before;
901 std::vector<DungeonCustomCollisionSnapshot> after;
902 std::vector<CollisionRollbackEntry> rollback_entries;
903 before.reserve(preview.size());
904 after.reserve(preview.size());
905 rollback_entries.reserve(preview.size());
906
907 // Validate the complete batch before changing rooms or undo history.
908 for (const auto& expected : preview) {
909 const int room_id = expected.room_id;
910 if (!IsValidRoomId(room_id)) {
911 return absl::OutOfRangeError(absl::StrFormat(
912 "Minecart collision room 0x%03X is out of range", room_id));
913 }
914 if (!seen_room_ids.insert(room_id).second) {
915 return absl::InvalidArgumentError(absl::StrFormat(
916 "Minecart collision preview contains duplicate room 0x%03X",
917 room_id));
918 }
919
920 zelda3::Room* room = rooms_.GetIfMaterialized(room_id);
921 if (room == nullptr) {
922 return absl::FailedPreconditionError(absl::StrFormat(
923 "Minecart collision room 0x%03X is not loaded", room_id));
924 }
925 if (HasAnyCustomCollision(room->custom_collision())) {
926 return absl::FailedPreconditionError(absl::StrFormat(
927 "Room 0x%03X already has custom collision; generation will not "
928 "replace it",
929 room_id));
930 }
931
932 ASSIGN_OR_RETURN(auto current,
933 zelda3::GenerateTrackCollision(room, options));
934 current.room_id = room_id;
935 if (!current.collision_map.has_data || current.tiles_generated <= 0) {
936 return absl::FailedPreconditionError(absl::StrFormat(
937 "Room 0x%03X no longer contains supported minecart track pieces",
938 room_id));
939 }
940 if (!CollisionMapsEqual(current.collision_map, expected.collision_map) ||
941 current.tiles_generated != expected.tiles_generated ||
942 current.stop_count != expected.stop_count ||
943 current.corner_count != expected.corner_count ||
944 current.switch_count != expected.switch_count) {
945 return absl::FailedPreconditionError(absl::StrFormat(
946 "Minecart collision preview for room 0x%03X is stale; preview "
947 "again before applying",
948 room_id));
949 }
950
951 before.push_back({room_id, room->custom_collision()});
952 after.push_back({room_id, current.collision_map});
953 rollback_entries.push_back(
954 {room, room->custom_collision(), room->custom_collision_dirty()});
955 }
956
960 std::any_of(pending_entity_undo_.begin(), pending_entity_undo_.end(),
961 [](const auto& pending) { return pending.room_id >= 0; })) {
962 return absl::FailedPreconditionError(
963 "Finish the current dungeon edit before applying minecart collision");
964 }
965
966 auto action = std::make_unique<DungeonCustomCollisionBatchAction>(
967 before, after,
968 [this](const std::vector<DungeonCustomCollisionSnapshot>& snapshots) {
969 return RestoreRoomCustomCollisionBatch(snapshots);
970 });
971
972 CollisionBatchRollback rollback(std::move(rollback_entries));
973 for (const auto& snapshot : after) {
974 auto* room = rooms_.GetIfMaterialized(snapshot.room_id);
975 room->custom_collision() = snapshot.map;
976 room->MarkCustomCollisionDirty();
977 }
978 try {
979 undo_manager_.Push(std::move(action));
980 } catch (const std::exception& error) {
981 return absl::InternalError(absl::StrFormat(
982 "Could not record minecart collision undo: %s", error.what()));
983 }
984 rollback.Commit();
987 }
988
989 for (const auto& snapshot : after) {
990 if (auto* viewer = GetViewerForRoom(snapshot.room_id)) {
991 viewer->TriggerChangePing();
992 }
993 }
994 return absl::OkStatus();
995}
996
998 const std::vector<DungeonCustomCollisionSnapshot>& snapshots) {
999 std::unordered_set<int> seen_room_ids;
1000 for (const auto& snapshot : snapshots) {
1001 if (!IsValidRoomId(snapshot.room_id)) {
1002 return absl::OutOfRangeError("Collision undo room ID is out of range");
1003 }
1004 if (!seen_room_ids.insert(snapshot.room_id).second) {
1005 return absl::InvalidArgumentError(
1006 "Collision undo contains a duplicate room ID");
1007 }
1008 if (rooms_.GetIfMaterialized(snapshot.room_id) == nullptr) {
1009 return absl::FailedPreconditionError(
1010 "Collision undo target room is not loaded");
1011 }
1012 }
1013
1014 for (const auto& snapshot : snapshots) {
1015 auto& room = *rooms_.GetIfMaterialized(snapshot.room_id);
1016 room.custom_collision() = snapshot.map;
1017 room.MarkCustomCollisionDirty();
1018 if (auto* viewer = GetViewerForRoom(snapshot.room_id)) {
1019 viewer->TriggerChangePing();
1021 }
1022 }
1025 }
1026 return absl::OkStatus();
1027}
1028
1029namespace {
1030
1032 WaterFillSnapshot snap;
1034
1035 const auto& zone = room.water_fill_zone();
1036 // Preserve deterministic ordering (ascending offsets) for stable diffs.
1037 for (size_t i = 0; i < zone.tiles.size(); ++i) {
1038 if (zone.tiles[i] != 0) {
1039 snap.offsets.push_back(static_cast<uint16_t>(i));
1040 }
1041 }
1042 return snap;
1043}
1044
1045} // namespace
1046
1048 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
1049 return;
1050
1053 }
1054
1056 pending_water_fill_undo_.before = MakeWaterFillSnapshot(rooms_[room_id]);
1057}
1058
1061 pending_water_fill_undo_.room_id != room_id) {
1062 return;
1063 }
1064 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
1065 return;
1066
1067 auto after = MakeWaterFillSnapshot(rooms_[room_id]);
1068 if (pending_water_fill_undo_.before.sram_bit_mask == after.sram_bit_mask &&
1069 pending_water_fill_undo_.before.offsets == after.offsets) {
1072 return;
1073 }
1074
1075 auto action = std::make_unique<DungeonWaterFillAction>(
1076 room_id, std::move(pending_water_fill_undo_.before), std::move(after),
1077 [this](int rid, const WaterFillSnapshot& snap) {
1078 RestoreRoomWaterFill(rid, snap);
1079 });
1080 undo_manager_.Push(std::move(action));
1081
1084}
1085
1087 const WaterFillSnapshot& snap) {
1088 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size()))
1089 return;
1090
1091 auto& room = rooms_[room_id];
1092 room.ClearWaterFillZone();
1093 room.set_water_fill_sram_bit_mask(snap.sram_bit_mask);
1094 for (uint16_t off : snap.offsets) {
1095 const int x = static_cast<int>(off % 64);
1096 const int y = static_cast<int>(off / 64);
1097 room.SetWaterFillTile(x, y, /*filled=*/true);
1098 }
1099 room.MarkWaterFillDirty();
1100}
1101
1102} // namespace yaze::editor
void set_dirty(bool dirty)
Definition rom.h:189
const auto & vector() const
Definition rom.h:205
auto size() const
Definition rom.h:200
bool is_loaded() const
Definition rom.h:187
InteractionContext * context() const
Get the interaction context.
void SetDoorMutationPreflight(DoorMutationPreflight callback)
void SetChestEditCallback(ChestEditCallback callback)
void SetMetadataBatchEditCallback(MetadataBatchEditCallback callback)
void SetDoorConnectionCallbacks(DoorConnectionPreviewCallback preview, DoorConnectionApplyCallback apply)
void SetRoomTransferCallbacks(RoomDocumentExportCallback export_document, RoomTransferPreviewCallback preview, RoomTransferApplyCallback apply)
DungeonObjectInteraction & object_interaction()
void SetChestDeleteCallback(ChestDeleteCallback callback)
void SetMetadataEditCallback(MetadataEditCallback callback)
void SetDoorConnectionNavigationCallback(std::function< void(int, size_t)> callback)
class MinecartTrackEditorPanel * minecart_track_editor_panel_
absl::Status EditRoomGraphicsSheet(int room_id, int slot, int sheet_id)
void ConfigureViewerUndoHooks(DungeonCanvasViewer *viewer)
absl::Status PreflightObjectMutation(int room_id, const std::vector< zelda3::RoomObject > &objects, const std::vector< chest_data > &chests, bool check_streams=true)
void RestoreRoomWaterFill(int room_id, const WaterFillSnapshot &snap)
absl::Status RestoreRoomEntities(int room_id, MutationDomain domain, const DungeonEntitySnapshot &snapshot)
void BeginEntityUndoSnapshot(int room_id, MutationDomain domain)
absl::Status ApplyRoomTransfer(const DungeonRoomTransferPlan &plan)
util::LruCache< int, std::unique_ptr< DungeonCanvasViewer > > room_viewers_
DungeonEntitySnapshot CaptureRoomEntities(int room_id, MutationDomain domain)
absl::Status CommitSelectionEdit(const DungeonSelectionEditPlan &plan, bool continuous)
absl::Status PreflightPotItemPlacement(int room_id, const std::vector< zelda3::PotItem > &items)
absl::Status ApplyDoorConnection(const DungeonConnectionPlan &plan)
absl::Status EditRoomMetadataBatch(const std::vector< RoomMetadataRequest > &requests)
void OnRoomSelected(int room_id, bool request_focus=true)
absl::Status ApplyMinecartCollisionStamp(int room_id, const zelda3::CustomCollisionMap &expected, const MinecartCollisionStamp &stamp)
void FinalizeEntityUndoAction(int room_id, MutationDomain domain)
PendingCollisionUndo pending_collision_undo_
absl::Status PreflightDoorMutation(int room_id, const std::vector< zelda3::Room::Door > &doors)
absl::Status RestoreRoomObjects(int room_id, const std::vector< zelda3::RoomObject > &objects, const std::vector< size_t > &selected_indices, const std::vector< chest_data > &chests)
absl::Status PreflightSpriteMutation(int room_id, const std::vector< zelda3::Sprite > &sprites)
absl::Status EditRoomMetadata(int room_id, const RoomMetadataEdit &edit)
std::unique_ptr< DungeonCanvasViewer > workbench_compare_viewer_
PendingWaterFillUndo pending_water_fill_undo_
std::array< PendingEntityUndo, 3 > pending_entity_undo_
static bool IsValidRoomId(int room_id)
DungeonCanvasViewer * GetViewerForRoom(int room_id)
PendingSelectionUndo pending_selection_undo_
absl::StatusOr< std::string > ExportRoomDocument(int room_id)
absl::Status RestoreRoomCustomCollisionBatch(const std::vector< DungeonCustomCollisionSnapshot > &snapshots)
absl::Status DeleteChest(int room_id, size_t index)
absl::StatusOr< DungeonRoomTransferPlan > PreviewRoomTransfer(int target_room_id, int source_room_id, const std::string &json, const DungeonRoomTransferOptions &options)
DungeonSelectionClipboard selection_clipboard_
std::unique_ptr< DungeonCanvasViewer > workbench_viewer_
void RestoreRoomCustomCollision(int room_id, const zelda3::CustomCollisionMap &map)
absl::Status EditChest(int room_id, size_t index, uint8_t item_id, bool big_chest)
absl::Status ApplyMinecartCollisionBatch(const std::vector< zelda3::TrackCollisionResult > &preview, const zelda3::GeneratorOptions &options)
absl::StatusOr< DungeonConnectionPlan > PreviewDoorConnection(const DungeonConnectionRequest &request)
void SetCacheInvalidationCallback(std::function< void()> callback)
void SetSelectionEditCallbacks(std::function< absl::Status(const DungeonSelectionEditPlan &, bool)> edit, std::function< void()> finish, std::function< void(const absl::Status &)> error={})
InteractionCoordinator & entity_coordinator()
Get the interaction coordinator for entity handling.
void SetMutationCallback(std::function< void()> callback)
void SetSharedSelectionClipboard(DungeonSelectionClipboard *clipboard)
zelda3::Room * GetIfMaterialized(int room_id)
zelda3::Room * GetIfLoaded(int room_id)
EditorContext context() const
Definition editor.h:364
UndoManager undo_manager_
Definition editor.h:371
EditorDependencies dependencies_
Definition editor.h:370
EditorType type() const
Definition editor.h:343
SpriteInteractionHandler & sprite_handler()
void SetItemPlacementPreflight(ItemPlacementPreflight preflight)
void SetSpriteMutationPreflight(SpriteMutationPreflight callback)
void SetObjectMutationPreflight(ObjectMutationPreflight callback)
void SetMutationErrorCallback(std::function< void(const absl::Status &)> callback)
void SetBeforeObjectCommandCallback(std::function< void()> callback)
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)
Abstract base for all undoable actions (Command pattern)
Definition undo_action.h:20
void Push(std::unique_ptr< UndoAction > action)
absl::Status Redo()
Redo the top action. Returns error if stack is empty.
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)
absl::Status Undo()
Undo the top action. Returns error if stack is empty.
std::string Description() const override
Human-readable description (e.g., "Paint 12 tiles on map 5")
RoomGraphicsSheetAction(int group, int slot, uint8_t before, uint8_t after, Apply apply)
size_t MemoryUsage() const override
Approximate memory footprint for budget enforcement.
bool HasSheet(uint16_t sheet) const
True when the buffer holds all 4096 bytes of sheet.
static constexpr uint16_t kSheetCount
const CustomCollisionMap & custom_collision() const
Definition room.h:559
bool custom_collision_dirty() const
Definition room.h:596
uint8_t blockset() const
Definition room.h:1015
const WaterFillZoneMap & water_fill_zone() const
Definition room.h:601
const std::vector< Door > & GetDoors() const
Definition room.h:413
const std::vector< zelda3::Sprite > & GetSprites() const
Definition room.h:316
const std::vector< PotItem > & GetPotItems() const
Definition room.h:429
uint8_t water_fill_sram_bit_mask() const
Definition room.h:644
Locates graphics sheets in the ROM and writes edited sheets back.
#define LOG_ERROR(category, format,...)
Definition log.h:140
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
std::vector< SelectedEntity > ValidEntitySelection(const zelda3::Room &room, const std::vector< SelectedEntity > &selected)
bool SameEntityData(const DungeonEntitySnapshot &a, const DungeonEntitySnapshot &b)
bool CollisionMapsEqual(const zelda3::CustomCollisionMap &lhs, const zelda3::CustomCollisionMap &rhs)
bool HasAnyCustomCollision(const zelda3::CustomCollisionMap &map)
WaterFillSnapshot MakeWaterFillSnapshot(const zelda3::Room &room)
Editors are the view controllers for the application.
MutationDomain
Domain/type of mutation for undo + invalidation routing.
absl::StatusOr< zelda3::CustomCollisionMap > PreviewMinecartCollisionStamp(const zelda3::CustomCollisionMap &before, const MinecartCollisionStamp &stamp)
GfxSheetStorageKind GetGfxSheetStorageKind(uint16_t sheet_id)
constexpr bool HasCustomCollisionWriteSupport(std::size_t rom_size)
absl::StatusOr< TrackCollisionResult > GenerateTrackCollision(Room *room, const GeneratorOptions &options)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::optional< DungeonSelectionEditPlan > plan
std::vector< zelda3::RoomObject > before_objects
std::vector< DungeonSpriteSnapshot > sprites
std::vector< zelda3::PotItem > items
std::vector< SelectedEntity > entities
std::vector< zelda3::Room::Door > doors
std::array< std::array< uint8_t, 4 >, kNumRoomBlocksets > room_blockset_ids
Definition game_data.h:98