yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_editor_v2_persistence.cc
Go to the documentation of this file.
1// Persistence (ROM and project save) implementation for DungeonEditorV2. Split out of
2// dungeon_editor_v2.cc to keep that translation unit within the editor
3// source-size guardrail. Class declaration lives in dungeon_editor_v2.h.
4
5#include <algorithm>
6#include <cctype>
7#include <cstdio>
8#include <iterator>
9#include <map>
10#include <memory>
11#include <optional>
12#include <string>
13#include <utility>
14#include <vector>
15#include "absl/status/status.h"
16#include "absl/strings/cord.h"
17#include "absl/strings/str_cat.h"
18#include "absl/strings/str_format.h"
19#include "absl/strings/str_join.h"
20#include "absl/strings/string_view.h"
21#include "absl/types/span.h"
60#include "app/gui/core/icons.h"
63#include "core/features.h"
64#include "core/project.h"
65#include "dungeon_editor_v2.h"
66#include "imgui/imgui.h"
67#include "rom/snes.h"
68#include "rom/transaction.h"
69#include "util/log.h"
70#include "util/macro.h"
77#include "zelda3/dungeon/room.h"
80
81namespace yaze::editor {
82
83namespace {
84
86 "Minecart Track Editor has unpublished drafts. ROM Save/Apply never "
87 "publishes ASM source; use Publish Tracks in the Minecart Track Editor "
88 "or discard the drafts first.";
89
91 int only_room = -1) {
92 const auto& flags = core::FeatureFlags::get().dungeon;
93 absl::Status status;
94 rooms.ForEachMaterialized([&](int id, const zelda3::Room& room) {
95 if (!status.ok() || !room.chests_dirty() || !room.object_stream_dirty())
96 return;
97 const bool writes_objects = flags.kSaveObjects && room.IsLoaded() &&
98 (only_room < 0 || only_room == id);
99 if (writes_objects == flags.kSaveChests)
100 return;
101 status = absl::FailedPreconditionError(absl::StrFormat(
102 "Room 0x%03X has pending chest object and reward edits. Enable both "
103 "Room Objects and Chests, then apply that room or save all rooms "
104 "together before applying another room.",
105 id));
106 });
107 return status;
108}
109
110absl::Status SaveWaterFillZones(Rom* rom, DungeonRoomStore& rooms) {
111 if (!rom || !rom->is_loaded()) {
112 return absl::FailedPreconditionError("ROM not loaded");
113 }
114
115 bool any_dirty = false;
116 rooms.ForEachMaterialized([&any_dirty](int, const zelda3::Room& room) {
117 if (room.water_fill_dirty()) {
118 any_dirty = true;
119 }
120 });
121 if (!any_dirty) {
122 return absl::OkStatus();
123 }
124
125 // Preserve saved zones even when no viewer has materialized their room.
126 ASSIGN_OR_RETURN(auto saved_zones, zelda3::LoadWaterFillTable(rom));
127 std::map<int, zelda3::WaterFillZoneEntry> merged;
128 for (auto& zone : saved_zones)
129 merged[zone.room_id] = std::move(zone);
130 for (int room_id = 0; room_id < static_cast<int>(rooms.size()); ++room_id) {
131 auto* room = rooms.GetIfMaterialized(room_id);
132 if (room == nullptr) {
133 continue;
134 }
135 if (!room->has_water_fill_zone()) {
136 if (room->water_fill_dirty())
137 merged.erase(room_id);
138 continue;
139 }
140
141 const int tile_count = room->WaterFillTileCount();
142 if (tile_count <= 0) {
143 continue;
144 }
145 if (tile_count > 255) {
146 return absl::InvalidArgumentError(absl::StrFormat(
147 "Water fill zone in room 0x%02X has %d tiles (max 255)", room_id,
148 tile_count));
149 }
150
152 z.room_id = room_id;
154 z.fill_offsets.reserve(static_cast<size_t>(tile_count));
155
156 const auto& map = room->water_fill_zone().tiles;
157 for (size_t i = 0; i < map.size(); ++i) {
158 if (map[i] != 0) {
159 z.fill_offsets.push_back(static_cast<uint16_t>(i));
160 }
161 }
162 merged[room_id] = std::move(z);
163 }
164
165 std::vector<zelda3::WaterFillZoneEntry> zones;
166 for (auto& [id, zone] : merged)
167 zones.push_back(std::move(zone));
168
169 if (zones.size() > 8) {
170 return absl::InvalidArgumentError(absl::StrFormat(
171 "Too many water fill zones: %zu (max 8 fits in $7EF411 bitfield)",
172 zones.size()));
173 }
174
175 // Canonicalize SRAM mask assignment using the shared normalizer so editor
176 // save behavior matches JSON import/export workflows.
178 for (const auto& z : zones) {
179 if (auto* room = rooms.GetIfMaterialized(z.room_id)) {
180 room->set_water_fill_sram_bit_mask(z.sram_bit_mask);
181 }
182 }
183
186 [](int, zelda3::Room& room) { room.ClearWaterFillDirty(); });
187
188 return absl::OkStatus();
189}
190
191std::vector<std::pair<uint32_t, uint32_t>> CollectDirtyPotItemWriteRanges(
192 const project::YazeProject* project, const Rom* rom,
193 const DungeonRoomStore& rooms) {
194 std::vector<std::pair<uint32_t, uint32_t>> ranges;
195 if (rom == nullptr || !rom->is_loaded()) {
196 return ranges;
197 }
198
199 bool any_dirty = false;
200 rooms.ForEachMaterialized([&](int, const zelda3::Room& room) {
201 if (room.pot_items_dirty()) {
202 any_dirty = true;
203 }
204 });
205 if (!any_dirty) {
206 return ranges;
207 }
208
209 if (project != nullptr && project->hack_manifest.loaded()) {
210 const auto* layout = project->hack_manifest.GetDungeonStreamLayout(
212 if (layout != nullptr &&
213 layout->strategy == core::DungeonWriteStrategy::kRepackAll) {
214 for (const auto& range : layout->allocation_regions) {
215 ranges.emplace_back(yaze::SnesToPc(range.start),
216 yaze::SnesToPc(range.end));
217 }
218 const uint32_t pointer_width =
220 ? 3u
221 : 2u;
222 const uint32_t pointer_table_pc = yaze::SnesToPc(layout->pointer_table);
223 ranges.emplace_back(
224 pointer_table_pc,
225 pointer_table_pc + layout->pointer_count * pointer_width);
226 return ranges;
227 }
228 }
229
230 const auto& rom_data = rom->vector();
231 rooms.ForEachMaterialized([&](int room_id, const zelda3::Room& room) {
232 if (!room.pot_items_dirty()) {
233 return;
234 }
235 const int pointer_slot = zelda3::kRoomItemsPointers + room_id * 2;
236 if (pointer_slot < 0 ||
237 pointer_slot + 1 >= static_cast<int>(rom_data.size())) {
238 return;
239 }
240 const uint16_t pointer =
241 (static_cast<uint16_t>(rom_data[pointer_slot + 1]) << 8) |
242 rom_data[pointer_slot];
243 if (pointer < 0x8000) {
244 return;
245 }
246 const uint32_t stream_pc = yaze::SnesToPc(0x010000u | pointer);
247 const uint32_t encoded_size =
248 static_cast<uint32_t>(room.GetPotItems().size() * 3 + 2);
249 ranges.emplace_back(stream_pc, stream_pc + encoded_size);
250 });
251 return ranges;
252}
253
254absl::StatusOr<std::vector<std::pair<uint32_t, uint32_t>>>
259 rom, static_cast<int>(rooms.size()),
260 [&rooms](int room_id) { return rooms.GetIfMaterialized(room_id); }));
261 return plan.write_ranges();
262}
263
264std::vector<std::pair<uint32_t, uint32_t>> CollectDirtyWaterFillWriteRanges(
265 const Rom* rom, const DungeonRoomStore& rooms) {
266 std::vector<std::pair<uint32_t, uint32_t>> ranges;
267 if (rom == nullptr || !rom->is_loaded() ||
268 zelda3::kWaterFillTableEnd > static_cast<int>(rom->size())) {
269 return ranges;
270 }
271 bool any_dirty = false;
272 rooms.ForEachMaterialized([&any_dirty](int, const zelda3::Room& room) {
273 if (room.water_fill_dirty()) {
274 any_dirty = true;
275 }
276 });
277 if (any_dirty) {
278 ranges.emplace_back(zelda3::kWaterFillTableStart,
280 }
281 return ranges;
282}
283
284std::vector<std::pair<uint32_t, uint32_t>>
286 const DungeonRoomStore& rooms) {
287 std::vector<std::pair<uint32_t, uint32_t>> ranges;
288 const int pointer_table_size = zelda3::kNumberOfRooms * 3;
289 if (rom == nullptr || !rom->is_loaded() ||
290 zelda3::kCustomCollisionRoomPointers + pointer_table_size >
291 static_cast<int>(rom->size()) ||
292 zelda3::kCustomCollisionDataSoftEnd > static_cast<int>(rom->size())) {
293 return ranges;
294 }
295 bool any_dirty = false;
296 rooms.ForEachMaterialized([&any_dirty](int, const zelda3::Room& room) {
297 if (room.custom_collision_dirty()) {
298 any_dirty = true;
299 }
300 });
301 if (any_dirty) {
302 ranges.emplace_back(
304 zelda3::kCustomCollisionRoomPointers + pointer_table_size);
305 ranges.emplace_back(zelda3::kCustomCollisionDataPosition,
307 }
308 return ranges;
309}
310
312 Rom* rom, DungeonRoomStore& rooms) {
313 std::optional<zelda3::DungeonStreamLayout> repack_layout;
314 bool any_dirty = false;
315 rooms.ForEachMaterialized([&](int, const zelda3::Room& room) {
316 if (room.pot_items_dirty()) {
317 any_dirty = true;
318 }
319 });
320 if (any_dirty && project != nullptr && project->hack_manifest.loaded()) {
321 const auto* manifest_layout = project->hack_manifest.GetDungeonStreamLayout(
323 if (manifest_layout != nullptr &&
324 manifest_layout->strategy == core::DungeonWriteStrategy::kRepackAll) {
326 zelda3::DungeonStreamLayout allocator_layout,
328 core::DungeonStreamType::kPotItems, *manifest_layout));
329 repack_layout = std::move(allocator_layout);
330 }
331 }
332
334 rom, static_cast<int>(rooms.size()),
335 [&rooms](int room_id) { return rooms.GetIfMaterialized(room_id); },
336 repack_layout.has_value() ? &*repack_layout : nullptr);
337}
338
340 const project::YazeProject* project, Rom* rom,
341 const DungeonRoomStore& rooms, absl::string_view save_scope,
342 ToastManager* toast_manager) {
343 if (project == nullptr || !project->hack_manifest.loaded()) {
344 return absl::OkStatus();
345 }
346 const auto ranges = CollectDirtyPotItemWriteRanges(project, rom, rooms);
347 if (ranges.empty()) {
348 return absl::OkStatus();
349 }
351 project->hack_manifest, project->rom_metadata.write_policy, ranges,
352 save_scope, "DungeonEditorV2", toast_manager);
353}
354
356 const project::YazeProject* project,
357 const gfx::PaletteManager& palette_manager,
358 const zelda3::GameData* game_data, absl::string_view save_scope,
359 ToastManager* toast_manager) {
360 ASSIGN_OR_RETURN(const auto ranges,
361 palette_manager.GetModifiedColorWriteRanges(game_data));
362 if (project == nullptr || !project->hack_manifest.loaded()) {
363 return absl::OkStatus();
364 }
365 if (ranges.empty()) {
366 return absl::OkStatus();
367 }
369 project->hack_manifest, project->rom_metadata.write_policy, ranges,
370 save_scope, "DungeonEditorV2", toast_manager);
371}
372
374 const project::YazeProject* project, Rom* rom,
375 const std::array<zelda3::RoomEntrance, zelda3::kNumDungeonEntranceSlots>&
376 entrances,
377 const std::array<zelda3::DungeonSpawnPoint, zelda3::kNumDungeonSpawnPoints>&
378 spawn_points,
379 absl::string_view save_scope, ToastManager* toast_manager) {
380 if (rom == nullptr || !rom->is_loaded()) {
381 return absl::FailedPreconditionError("ROM not loaded");
382 }
383
384 // Field and ROM-bound validation must happen even without a manifest. This
385 // runs before any editor save domain mutates the shared ROM buffer.
391
392 if (project == nullptr || !project->hack_manifest.loaded()) {
393 return absl::OkStatus();
394 }
396 auto ranges,
398 auto spawn_ranges =
400 ranges.insert(ranges.end(), spawn_ranges.begin(), spawn_ranges.end());
401 if (ranges.empty()) {
402 return absl::OkStatus();
403 }
405 project->hack_manifest, project->rom_metadata.write_policy, ranges,
406 save_scope, "DungeonEditorV2", toast_manager);
407}
408
410 const zelda3::ChestSavePlan& plan,
411 absl::string_view save_scope,
412 ToastManager* toast_manager) {
413 const auto ranges = plan.write_ranges();
414 if (ranges.empty() || project == nullptr ||
415 !project->hack_manifest.loaded()) {
416 return absl::OkStatus();
417 }
419 project->hack_manifest, project->rom_metadata.write_policy, ranges,
420 save_scope, "DungeonEditorV2", toast_manager);
421}
422
424 const project::YazeProject* project, const Rom* rom,
425 const DungeonRoomStore& rooms, absl::string_view save_scope,
426 ToastManager* toast_manager) {
427 if (project == nullptr || !project->hack_manifest.loaded()) {
428 return absl::OkStatus();
429 }
430 const auto ranges = CollectDirtyWaterFillWriteRanges(rom, rooms);
431 if (ranges.empty()) {
432 return absl::OkStatus();
433 }
435 project->hack_manifest, project->rom_metadata.write_policy, ranges,
436 save_scope, "DungeonEditorV2", toast_manager);
437}
438
440 const project::YazeProject* project, const Rom* rom,
441 const DungeonRoomStore& rooms, absl::string_view save_scope,
442 ToastManager* toast_manager) {
443 if (project == nullptr || !project->hack_manifest.loaded()) {
444 return absl::OkStatus();
445 }
446 const auto ranges = CollectDirtyCustomCollisionWriteRanges(rom, rooms);
447 if (ranges.empty()) {
448 return absl::OkStatus();
449 }
451 project->hack_manifest, project->rom_metadata.write_policy, ranges,
452 save_scope, "DungeonEditorV2", toast_manager);
453}
454
455} // namespace
456
458 int room_id, std::optional<std::string> label) {
459 // Save Project serializes EditorManager's project and then replaces the
460 // session snapshot (dependencies_.project) from it, so the edit must land
461 // in the authoritative project first.
462 auto* editor_manager = static_cast<EditorManager*>(dependencies_.custom_data);
463 if (editor_manager == nullptr ||
464 !editor_manager->IsCurrentProjectContextOwnedBySession(
466 return absl::FailedPreconditionError(
467 "Activate this dungeon project before renaming rooms");
468 }
469 project::YazeProject* authoritative = editor_manager->GetCurrentProject();
470 if (authoritative == nullptr || !authoritative->project_opened()) {
471 return absl::FailedPreconditionError("Room names require an open project");
472 }
473
474 const auto apply = [&](project::YazeProject* project) {
475 if (label.has_value()) {
477 } else {
479 }
480 };
481 apply(authoritative);
482 if (dependencies_.project != nullptr &&
483 dependencies_.project != authoritative) {
484 apply(dependencies_.project);
485 }
486 editor_manager->MarkCurrentProjectDirty();
487 editor_manager->RefreshResourceLabelProvider();
488 return absl::OkStatus();
489}
490
492 int room_id, const std::vector<zelda3::PotItem>& items) {
493 const auto* room = rooms_.GetIfLoaded(room_id);
494 if (!rom_ || !rom_->is_loaded() || !room || room->rom() != rom_) {
495 return absl::FailedPreconditionError("Pot-item room is not loaded");
496 }
497 // Pot streams share one save operation. Include pending edits in other rooms
498 // so an individually small placement cannot overcommit the repack pool.
499 // Only detached rooms can have their dirty flags cleared by this dry run.
500 Rom scratch(*rom_);
501 DungeonRoomStore staged(&scratch);
502 rooms_.ForEachMaterialized([&](int id, const zelda3::Room& source) {
503 if (id != room_id && !source.pot_items_dirty()) {
504 return;
505 }
506 auto& candidate = staged[id];
507 candidate.GetPotItems() = id == room_id ? items : source.GetPotItems();
508 candidate.MarkPotItemsDirty();
509 });
510 RETURN_IF_ERROR(ValidatePotItemManifestConflicts(
511 dependencies_.project, &scratch, staged, "pot-item placement", nullptr));
512 return SaveAllPotItemsForProject(dependencies_.project, &scratch, staged);
513}
514
516 if (save_transaction_snapshot_.has_value()) {
517 return absl::FailedPreconditionError(
518 "Dungeon save transaction is already active");
519 }
520
521 // A save is a history boundary: a later drag increment must not restore
522 // dirty flags captured before the bytes we are about to publish.
525 auto& palette_manager = gfx::PaletteManager::Get();
526 if (core::FeatureFlags::get().dungeon.kSavePalettes &&
527 palette_manager.HasUnsavedChanges(game_data_)) {
528 if (!palette_manager.IsManaging(game_data_)) {
529 return absl::FailedPreconditionError(
530 "Cannot save dungeon palettes from a different ROM session");
531 }
532 RETURN_IF_ERROR(palette_manager.BeginSaveTransaction());
533 snapshot.has_palette_transaction = true;
534 }
535 rooms_.ForEachMaterialized([&snapshot](int room_id,
536 const zelda3::Room& room) {
537 snapshot.room_states.emplace_back(room_id, room.CaptureSaveDirtySnapshot());
538 });
539 for (size_t i = 0; i < entrances_.size(); ++i) {
540 snapshot.entrance_dirty_states[i] = entrances_[i].dirty();
541 }
542 for (size_t i = 0; i < spawn_points_.size(); ++i) {
543 snapshot.spawn_dirty_states[i] = spawn_points_[i].dirty();
544 }
545 if (game_data_ != nullptr) {
546 snapshot.has_pit_damage_table = true;
548 }
549 save_transaction_snapshot_ = std::move(snapshot);
550 return absl::OkStatus();
551}
552
554 if (!save_transaction_snapshot_.has_value()) {
555 return;
556 }
557
558 for (const auto& [room_id, state] : save_transaction_snapshot_->room_states) {
559 if (auto* room = rooms_.GetIfMaterialized(room_id); room != nullptr) {
560 room->RestoreSaveDirtySnapshot(state);
561 }
562 }
563 for (size_t i = 0; i < entrances_.size(); ++i) {
564 if (save_transaction_snapshot_->entrance_dirty_states[i]) {
565 entrances_[i].MarkDirty();
566 } else {
567 entrances_[i].ClearDirty();
568 }
569 }
570 for (size_t i = 0; i < spawn_points_.size(); ++i) {
571 if (save_transaction_snapshot_->spawn_dirty_states[i]) {
572 spawn_points_[i].MarkDirty();
573 } else {
574 spawn_points_[i].ClearDirty();
575 }
576 }
577 if (save_transaction_snapshot_->has_pit_damage_table &&
578 game_data_ != nullptr) {
579 if (save_transaction_snapshot_->pit_damage_dirty) {
581 } else {
583 }
584 }
585 if (save_transaction_snapshot_->has_palette_transaction) {
587 }
589}
590
601
603 const std::function<absl::Status()>& operation) {
604 if (!rom_ || !rom_->is_loaded()) {
605 return absl::FailedPreconditionError("ROM not loaded");
606 }
607
608 // File > Save already owns a whole-ROM/editor transaction. Direct Apply Room
609 // and Apply All actions use the same guarantees without nesting it.
610 if (save_transaction_snapshot_.has_value()) {
611 return operation();
612 }
613
614 ScopedRomTransaction rom_transaction(*rom_);
616
617 const absl::Status status = operation();
618 if (!status.ok()) {
620 return status;
621 }
622
624 rom_transaction.Commit();
625 return absl::OkStatus();
626}
627
628absl::Status DungeonEditorV2::ValidateSaveScope(int only_room,
629 bool room_data_only) const {
630 // A chest object and its reward are one edit. Preserve the more specific
631 // recovery instruction when only one half of that edit would be written.
632 RETURN_IF_ERROR(ValidateChestObjectSaveCoupling(rooms_, only_room));
633 const auto& flags = core::FeatureFlags::get().dungeon;
635 bool collision = false;
636 bool water_fill = false;
637 rooms_.ForEachMaterialized([&](int id, const zelda3::Room& room) {
638 // Apply Room writes local streams for its selected room, but the compact
639 // shared tables below include pending records from every materialized room.
640 if (only_room < 0 || only_room == id) {
641 pending.object_stream |=
643 pending.sprites |= room.sprites_dirty();
644 pending.header |= room.header_dirty();
645 }
646 pending.chests |= room.chests_dirty();
647 pending.pot_items |= room.pot_items_dirty();
648 pending.torches |= room.torches_dirty();
649 pending.blocks |= room.blocks_dirty();
650 collision |= room.custom_collision_dirty();
651 water_fill |= room.water_fill_dirty();
652 });
653 std::vector<std::string> excluded;
654 auto check = [&](bool dirty, bool enabled, const char* label) {
655 if (dirty && !enabled) {
656 excluded.emplace_back(label);
657 }
658 };
659 check(pending.object_stream, flags.kSaveObjects, "Save Objects");
660 check(pending.sprites, flags.kSaveSprites, "Save Sprites");
661 check(pending.header, flags.kSaveRoomHeaders, "Save Room Headers");
662 check(pending.torches, flags.kSaveTorches, "Save Torches");
663 check(pending.blocks, flags.kSaveBlocks, "Save Blocks");
664 check(collision, flags.kSaveCollision, "Save Collision");
665 check(pending.chests, flags.kSaveChests, "Save Chests");
666 check(pending.pot_items, flags.kSavePotItems, "Save Pot Items");
667 if (!room_data_only) {
668 check(game_data_ && game_data_->pit_damage_table.dirty(), flags.kSavePits,
669 "Save Pits");
670 const auto dirty = [](const auto& entrance) {
671 return entrance.dirty();
672 };
673 check(std::any_of(entrances_.begin(), entrances_.end(), dirty) ||
674 std::any_of(spawn_points_.begin(), spawn_points_.end(), dirty),
675 flags.kSaveEntrances, "Save Entrances");
676 }
677 std::string message;
678 if (!excluded.empty()) {
679 message = absl::StrCat(
680 "Save blocked: pending dungeon edits are excluded by ",
681 absl::StrJoin(excluded, ", "),
682 ". Open File > Save Scope > Dungeon, enable these choices and retry "
683 "Save or Apply Room. Or undo the excluded edits. No project reopen "
684 "is needed.");
685 }
686 // Water Fill import/migration is established at project load, so keep its
687 // existing project-only scope contract rather than silently enabling it.
688 if (water_fill && !flags.kSaveWaterFillZones) {
689 absl::StrAppend(&message, message.empty() ? "Save blocked: " : " ",
690 "Water Fill edits are pending while their project save "
691 "scope is disabled. Undo these edits or restore the "
692 "project's Water Fill save setting before saving.");
693 }
694 if (!message.empty()) {
695 return absl::FailedPreconditionError(message);
696 }
697 if (!flags.kSaveRoomHeaders) {
698 return absl::OkStatus();
699 }
700 // Check every header this operation writes before palettes or any earlier
701 // room in the save loop can change ROM bytes.
702 absl::Status ownership;
703 rooms_.ForEachLoaded([&](int id, const zelda3::Room& room) {
704 if (!ownership.ok() || !room.header_dirty() ||
705 (only_room >= 0 && only_room != id)) {
706 return;
707 }
708 if (!rom_ || !rom_->is_loaded()) {
709 ownership = absl::FailedPreconditionError("ROM not loaded");
710 } else if (room.rom() != rom_) {
711 ownership = absl::FailedPreconditionError(
712 absl::StrFormat("Room 0x%03X belongs to another ROM", id));
713 } else {
714 ownership = ValidateRoomHeaderOwnership(*rom_, room.id());
715 }
716 });
717 return ownership;
718}
719
721 int room_id) {
722 if (!IsValidRoomId(room_id)) {
723 return absl::InvalidArgumentError("Invalid room ID");
724 }
725 ASSIGN_OR_RETURN(const auto table_snes,
727 ASSIGN_OR_RETURN(const auto bank,
729 const auto table_bank = (table_snes >> 16) & 0xFF;
730 if ((table_snes & 0xFFFF) < 0x8000 || table_bank == 0x7E ||
731 table_bank == 0x7F || table_bank == 0xFE || table_bank == 0xFF ||
732 bank == 0x7E || bank == 0x7F || bank == 0xFE || bank == 0xFF) {
733 return absl::FailedPreconditionError("Invalid room header pointer table");
734 }
735 const auto table_pc = SnesToPc(table_snes);
736 if (static_cast<uint64_t>(table_pc) + zelda3::kNumberOfRooms * 2 >
737 rom.size()) {
738 return absl::FailedPreconditionError(
739 "Room header pointer table is truncated");
740 }
741 ASSIGN_OR_RETURN(const auto header, rom.ReadWord(table_pc + room_id * 2));
742 const auto header_pc = SnesToPc((bank << 16) | header);
743 if (header < 0x8000 || static_cast<uint64_t>(header_pc) + 14 > rom.size()) {
744 return absl::FailedPreconditionError(
745 absl::StrFormat("Room %03X header pointer is invalid", room_id));
746 }
747 for (int other_id = 0; other_id < zelda3::kNumberOfRooms; ++other_id) {
748 if (other_id == room_id)
749 continue;
750 ASSIGN_OR_RETURN(const auto other_header,
751 rom.ReadWord(table_pc + other_id * 2));
752 const auto other_pc = SnesToPc((bank << 16) | other_header);
753 if (other_header < 0x8000 ||
754 static_cast<uint64_t>(other_pc) + 14 > rom.size())
755 continue;
756 if (header_pc < other_pc + 14 && other_pc < header_pc + 14) {
757 auto status = absl::FailedPreconditionError(absl::StrFormat(
758 "Room %03X header overlaps room %03X; writing its room properties "
759 "would also change that room. Undo or exclude the room property "
760 "edits and retry.",
761 room_id, other_id));
762 status.SetPayload(kRoomTransferSharedHeaderPayload, absl::Cord("1"));
763 return status;
764 }
765 }
766 return absl::OkStatus();
767}
768
769absl::Status DungeonEditorV2::Save() {
771 if (minecart_track_editor_panel_ != nullptr &&
773 return absl::FailedPreconditionError(kMinecartDraftSaveBlockMessage);
774 }
775 if (!rom_ || !rom_->is_loaded()) {
776 return absl::FailedPreconditionError("ROM not loaded");
777 }
778 if (object_tile_editor_panel_ != nullptr &&
780 return absl::FailedPreconditionError(
781 "Apply or explicitly discard Object Tile Editor changes before "
782 "using Save or Apply Room");
783 }
784
786 // A sprite/item drag can first become dirty when the gesture is released.
787 // Direct Save callers need the same post-finalization check as File > Save.
789 const auto& flags = core::FeatureFlags::get().dungeon;
790 std::optional<zelda3::ChestSavePlan> chest_save_plan;
791
792 // Simulate the pending room streams in save order before any ROM writer.
793 // Earlier relocations can change where later streams and headers will write.
794 ASSIGN_OR_RETURN(const auto stream_ranges, PlanPendingRoomStreamRanges({}));
799 "dungeon room streams", "DungeonEditorV2",
801 }
802
803 if (flags.kSaveCollision) {
804 RETURN_IF_ERROR(ValidateCustomCollisionManifestConflicts(
805 dependencies_.project, rom_, rooms_, "custom collision",
807 }
808 if (flags.kSaveWaterFillZones) {
809 RETURN_IF_ERROR(ValidateWaterFillManifestConflicts(
810 dependencies_.project, rom_, rooms_, "water fill zones",
812 }
813 if (flags.kSaveEntrances) {
814 RETURN_IF_ERROR(ValidateDungeonEntranceSavePreflight(
816 "dungeon entrances and spawn points", dependencies_.toast_manager));
817 }
818
819 if (flags.kSaveChests) {
821 rom_, static_cast<int>(rooms_.size()),
822 [this](int room_id) {
823 return rooms_.GetIfMaterialized(room_id);
824 }));
825 RETURN_IF_ERROR(ValidateChestManifestConflicts(
826 dependencies_.project, plan, "chest table",
828 chest_save_plan = std::move(plan);
829 }
830 if (flags.kSavePotItems) {
831 RETURN_IF_ERROR(ValidatePotItemManifestConflicts(
832 dependencies_.project, rom_, rooms_, "pot items",
834 }
835
836 auto& palette_manager = gfx::PaletteManager::Get();
837 if (flags.kSavePalettes && palette_manager.HasUnsavedChanges(game_data_)) {
838 if (!palette_manager.IsManaging(game_data_)) {
839 return absl::FailedPreconditionError(
840 "Cannot save dungeon palettes from a different ROM session");
841 }
842 RETURN_IF_ERROR(ValidatePaletteManifestConflicts(
843 dependencies_.project, palette_manager, game_data_, "dungeon palettes",
845 const size_t modified_color_count =
846 palette_manager.GetModifiedColorCount(game_data_);
847 auto status = palette_manager.SaveAllToRom();
848 if (!status.ok()) {
849 LOG_ERROR("DungeonEditorV2", "Failed to save palette changes: %s",
850 status.message().data());
851 return status;
852 }
853 LOG_INFO("DungeonEditorV2", "Saved %zu modified colors to ROM",
854 modified_color_count);
855 }
856
857 if (flags.kSaveObjects || flags.kSaveSprites || flags.kSaveRoomHeaders) {
858 absl::Status save_status = absl::OkStatus();
859 rooms_.ForEachLoaded([&](int room_id, zelda3::Room&) {
860 if (!save_status.ok()) {
861 return;
862 }
863 auto status = SaveRoomData(room_id);
864 if (!status.ok()) {
865 save_status = status;
866 }
867 });
868 if (!save_status.ok()) {
869 return save_status;
870 }
871 }
872
873 if (flags.kSaveTorches) {
874 auto status = zelda3::SaveAllTorches(
875 rom_, static_cast<int>(rooms_.size()),
876 [this](int room_id) { return rooms_.GetIfMaterialized(room_id); });
877 if (!status.ok()) {
878 LOG_ERROR("DungeonEditorV2", "Failed to save torches: %s",
879 status.message().data());
880 return status;
881 }
882 }
883
884 if (flags.kSavePits) {
885 auto status = zelda3::SaveAllPits(
887 if (!status.ok()) {
888 LOG_ERROR("DungeonEditorV2", "Failed to save pits: %s",
889 status.message().data());
890 return status;
891 }
892 }
893
894 if (flags.kSaveBlocks) {
895 auto status = zelda3::SaveAllBlocks(
896 rom_, static_cast<int>(rooms_.size()),
897 [this](int room_id) { return rooms_.GetIfMaterialized(room_id); });
898 if (!status.ok()) {
899 LOG_ERROR("DungeonEditorV2", "Failed to save blocks: %s",
900 status.message().data());
901 return status;
902 }
903 }
904
905 if (flags.kSaveCollision) {
906 auto status = zelda3::SaveAllCollision(
907 rom_, static_cast<int>(rooms_.size()),
908 [this](int room_id) { return rooms_.GetIfMaterialized(room_id); });
909 if (!status.ok()) {
910 LOG_ERROR("DungeonEditorV2", "Failed to save collision: %s",
911 status.message().data());
912 return status;
913 }
914 }
915
916 if (flags.kSaveWaterFillZones) {
917 auto status = SaveWaterFillZones(rom_, rooms_);
918 if (!status.ok()) {
919 LOG_ERROR("DungeonEditorV2", "Failed to save water fill zones: %s",
920 status.message().data());
921 return status;
922 }
923 }
924
925 if (flags.kSaveChests) {
926 auto status = zelda3::ApplyChestSavePlan(
927 rom_, *chest_save_plan,
928 [this](int room_id) { return rooms_.GetIfMaterialized(room_id); });
929 if (!status.ok()) {
930 LOG_ERROR("DungeonEditorV2", "Failed to save chests: %s",
931 status.message().data());
932 return status;
933 }
934 }
935
936 if (flags.kSavePotItems) {
937 auto status =
938 SaveAllPotItemsForProject(dependencies_.project, rom_, rooms_);
939 if (!status.ok()) {
940 LOG_ERROR("DungeonEditorV2", "Failed to save pot items: %s",
941 status.message().data());
942 return status;
943 }
944 }
945
946 if (flags.kSaveEntrances) {
948 if (!status.ok()) {
949 LOG_ERROR("DungeonEditorV2", "Failed to save dungeon entrances: %s",
950 status.message().data());
951 return status;
952 }
954 if (!status.ok()) {
955 LOG_ERROR("DungeonEditorV2", "Failed to save dungeon spawn points: %s",
956 status.message().data());
957 return status;
958 }
959 }
960
961 return absl::OkStatus();
962}
963
964std::vector<std::pair<uint32_t, uint32_t>> DungeonEditorV2::CollectWriteRanges()
965 const {
966 std::vector<std::pair<uint32_t, uint32_t>> ranges;
967
968 if (!rom_ || !rom_->is_loaded()) {
969 return ranges;
970 }
971
972 const auto& flags = core::FeatureFlags::get().dungeon;
973 const auto& rom_data = rom_->vector();
974
975 auto& palette_manager = gfx::PaletteManager::Get();
976 if (flags.kSavePalettes) {
977 auto palette_ranges =
978 palette_manager.GetModifiedColorWriteRanges(game_data_);
979 if (palette_ranges.ok()) {
980 ranges.insert(ranges.end(), palette_ranges->begin(),
981 palette_ranges->end());
982 } else {
983 LOG_ERROR("DungeonEditorV2", "Cannot predict palette write ranges: %s",
984 palette_ranges.status().message().data());
985 }
986 // This legacy prediction API cannot return a status. Both actual save
987 // paths reject resolution errors in ValidatePaletteManifestConflicts
988 // before any palette writer, including when no manifest is loaded.
989 }
990
991 if (flags.kSaveEntrances) {
992 auto entrance_ranges =
994 entrances_);
995 if (entrance_ranges.ok()) {
996 ranges.insert(ranges.end(), entrance_ranges->begin(),
997 entrance_ranges->end());
998 }
999 // Invalid layouts are rejected by ValidateDungeonEntranceSavePreflight
1000 // before any save domain mutates bytes. Do not predict legacy ranges here.
1001 auto spawn_ranges =
1003 ranges.insert(ranges.end(), spawn_ranges.begin(), spawn_ranges.end());
1004 }
1005
1006 const auto stream_ranges = PlanPendingRoomStreamRanges({});
1007 if (stream_ranges.ok()) {
1008 ranges.insert(ranges.end(), stream_ranges->begin(), stream_ranges->end());
1009 } else {
1010 // Save repeats this preflight and fails before mutating any ROM bytes.
1011 LOG_ERROR("DungeonEditorV2",
1012 "Could not predict room stream write ranges: %s",
1013 stream_ranges.status().message().data());
1014 }
1015
1016 // Oracle of Secrets: the water fill table lives in a reserved tail region.
1017 // Include it in write-range reporting whenever we have dirty water fill data,
1018 // even if no rooms are currently loaded (SaveWaterFillZones() is room-indexed
1019 // and independent of room loading state).
1020 if (flags.kSaveWaterFillZones) {
1021 auto water_fill_ranges = CollectDirtyWaterFillWriteRanges(rom_, rooms_);
1022 ranges.insert(ranges.end(), water_fill_ranges.begin(),
1023 water_fill_ranges.end());
1024 }
1025
1026 // Custom collision writes update the pointer table and append blobs into the
1027 // expanded collision region. SaveAllCollision() is room-indexed, so include
1028 // these ranges whenever any room has dirty custom collision data.
1029 if (flags.kSaveCollision) {
1030 auto collision_ranges =
1031 CollectDirtyCustomCollisionWriteRanges(rom_, rooms_);
1032 ranges.insert(ranges.end(), collision_ranges.begin(),
1033 collision_ranges.end());
1034 }
1035
1036 // Chests use one global fixed-capacity table. Report only the exact byte
1037 // deltas from the same planner used by the serializer so unrelated protected
1038 // bytes inside the live table do not block a safe edit.
1039 if (flags.kSaveChests) {
1040 auto chest_ranges = CollectDirtyChestWriteRanges(rom_, rooms_);
1041 if (chest_ranges.ok()) {
1042 ranges.insert(ranges.end(), chest_ranges->begin(), chest_ranges->end());
1043 } else {
1044 LOG_WARN("DungeonEditorV2", "Could not predict chest write ranges: %s",
1045 chest_ranges.status().message().data());
1046 }
1047 }
1048
1049 rooms_.ForEachLoaded([&](int room_id, const zelda3::Room& room) {
1050 room_id = room.id();
1051
1052 // Header range
1053 if (flags.kSaveRoomHeaders && room.header_dirty()) {
1054 if (zelda3::kRoomHeaderPointer + 2 < static_cast<int>(rom_data.size())) {
1055 int header_ptr_table =
1056 (rom_data[zelda3::kRoomHeaderPointer + 2] << 16) |
1057 (rom_data[zelda3::kRoomHeaderPointer + 1] << 8) |
1058 rom_data[zelda3::kRoomHeaderPointer];
1059 header_ptr_table = yaze::SnesToPc(header_ptr_table);
1060 int table_offset = header_ptr_table + (room_id * 2);
1061
1062 if (table_offset + 1 < static_cast<int>(rom_data.size())) {
1063 int address = (rom_data[zelda3::kRoomHeaderPointerBank] << 16) |
1064 (rom_data[table_offset + 1] << 8) |
1065 rom_data[table_offset];
1066 int header_location = yaze::SnesToPc(address);
1067 ranges.emplace_back(header_location, header_location + 14);
1068 }
1069 }
1070 const int message_address = zelda3::kMessagesIdDungeon + (room_id * 2);
1071 if (message_address >= 0 &&
1072 message_address + 1 < static_cast<int>(rom_data.size())) {
1073 ranges.emplace_back(message_address, message_address + 2);
1074 }
1075 }
1076 });
1077
1078 // Pot items are saved after room-local streams but still participate in the
1079 // same whole-ROM transaction and manifest preflight.
1080 if (flags.kSavePotItems) {
1081 auto pot_ranges =
1082 CollectDirtyPotItemWriteRanges(dependencies_.project, rom_, rooms_);
1083 ranges.insert(ranges.end(), pot_ranges.begin(), pot_ranges.end());
1084 }
1085
1086 return ranges;
1087}
1088
1089absl::Status DungeonEditorV2::SaveRoom(int room_id) {
1090 return SaveRoomImpl(room_id, false);
1091}
1092
1093// The room-only path is used exclusively by a detached scratch editor during
1094// transfer preflight. It cannot save project-global palettes, pits or entrances.
1095absl::Status DungeonEditorV2::SaveRoomImpl(int room_id, bool room_data_only) {
1096 if (minecart_track_editor_panel_ != nullptr &&
1097 minecart_track_editor_panel_->HasUnpublishedChanges()) {
1098 return absl::FailedPreconditionError(kMinecartDraftSaveBlockMessage);
1099 }
1100 if (object_tile_editor_panel_ != nullptr &&
1101 object_tile_editor_panel_->HasUnappliedChanges()) {
1102 return absl::FailedPreconditionError(
1103 "Apply or explicitly discard Object Tile Editor changes before "
1104 "using Save or Apply Room");
1105 }
1106 auto operation = [this, room_id, room_data_only]() -> absl::Status {
1107 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
1108 return absl::InvalidArgumentError("Invalid room ID");
1109 }
1110
1111 RETURN_IF_ERROR(ValidateSaveScope(room_id, room_data_only));
1112 const auto& flags = core::FeatureFlags::get().dungeon;
1113 std::optional<zelda3::ChestSavePlan> chest_save_plan;
1114 if (flags.kSaveCollision) {
1115 RETURN_IF_ERROR(ValidateCustomCollisionManifestConflicts(
1116 dependencies_.project, rom_, rooms_,
1117 absl::StrFormat("custom collision for room 0x%03X", room_id),
1118 dependencies_.toast_manager));
1119 }
1120 if (flags.kSaveWaterFillZones) {
1121 RETURN_IF_ERROR(ValidateWaterFillManifestConflicts(
1122 dependencies_.project, rom_, rooms_,
1123 absl::StrFormat("water fill zones for room 0x%03X", room_id),
1124 dependencies_.toast_manager));
1125 }
1126 if (!room_data_only && flags.kSaveEntrances) {
1127 RETURN_IF_ERROR(ValidateDungeonEntranceSavePreflight(
1128 dependencies_.project, rom_, entrances_, spawn_points_,
1129 absl::StrFormat(
1130 "dungeon entrances and spawn points (Apply Room 0x%03X)",
1131 room_id),
1132 dependencies_.toast_manager));
1133 }
1134 if (flags.kSaveChests) {
1136 auto plan, zelda3::BuildChestSavePlan(
1137 rom_, static_cast<int>(rooms_.size()), [this](int id) {
1138 return rooms_.GetIfMaterialized(id);
1139 }));
1140 RETURN_IF_ERROR(ValidateChestManifestConflicts(
1141 dependencies_.project, plan,
1142 absl::StrFormat("chest table for room 0x%03X", room_id),
1143 dependencies_.toast_manager));
1144 chest_save_plan = std::move(plan);
1145 }
1146 if (flags.kSavePotItems) {
1147 RETURN_IF_ERROR(ValidatePotItemManifestConflicts(
1148 dependencies_.project, rom_, rooms_,
1149 absl::StrFormat("pot items for room 0x%03X", room_id),
1150 dependencies_.toast_manager));
1151 }
1152 auto& palette_manager = gfx::PaletteManager::Get();
1153 if (!room_data_only && flags.kSavePalettes &&
1154 palette_manager.HasUnsavedChanges(game_data_)) {
1155 if (!palette_manager.IsManaging(game_data_)) {
1156 return absl::FailedPreconditionError(
1157 "Cannot save dungeon palettes from a different ROM session");
1158 }
1159 RETURN_IF_ERROR(ValidatePaletteManifestConflicts(
1160 dependencies_.project, palette_manager, game_data_,
1161 absl::StrFormat("dungeon palettes for room 0x%03X", room_id),
1162 dependencies_.toast_manager));
1163 auto status = palette_manager.SaveAllToRom();
1164 if (!status.ok()) {
1165 LOG_ERROR("DungeonEditorV2", "Failed to save palette changes: %s",
1166 status.message().data());
1167 return status;
1168 }
1169 }
1170 if (flags.kSaveObjects || flags.kSaveSprites || flags.kSaveRoomHeaders) {
1171 RETURN_IF_ERROR(SaveRoomData(room_id));
1172 }
1173
1174 if (flags.kSaveTorches) {
1176 rom_, static_cast<int>(rooms_.size()),
1177 [this](int id) { return rooms_.GetIfMaterialized(id); }));
1178 }
1179 if (!room_data_only && flags.kSavePits) {
1181 rom_, game_data_ ? &game_data_->pit_damage_table : nullptr));
1182 }
1183 if (flags.kSaveBlocks) {
1185 rom_, static_cast<int>(rooms_.size()),
1186 [this](int id) { return rooms_.GetIfMaterialized(id); }));
1187 }
1188 if (flags.kSaveCollision) {
1190 rom_, static_cast<int>(rooms_.size()),
1191 [this](int id) { return rooms_.GetIfMaterialized(id); }));
1192 }
1193 if (flags.kSaveWaterFillZones) {
1194 RETURN_IF_ERROR(SaveWaterFillZones(rom_, rooms_));
1195 }
1196 if (flags.kSaveChests) {
1198 rom_, *chest_save_plan,
1199 [this](int id) { return rooms_.GetIfMaterialized(id); }));
1200 }
1201 if (flags.kSavePotItems) {
1203 SaveAllPotItemsForProject(dependencies_.project, rom_, rooms_));
1204 }
1205 if (!room_data_only && flags.kSaveEntrances) {
1208 }
1209
1210 return absl::OkStatus();
1211 };
1212 // Scratch state is discarded after either outcome; no live palette/history
1213 // transaction should be started by a preview.
1214 return room_data_only ? operation() : RunWithSaveTransaction(operation);
1215}
1216
1217absl::Status DungeonEditorV2::SaveRoomData(int room_id) {
1218 if (!rom_ || !rom_->is_loaded()) {
1219 return absl::FailedPreconditionError("ROM not loaded");
1220 }
1221 if (room_id < 0 || room_id >= static_cast<int>(rooms_.size())) {
1222 return absl::InvalidArgumentError("Invalid room ID");
1223 }
1224
1225 auto& room = rooms_[room_id];
1226 if (!room.IsLoaded()) {
1227 return absl::OkStatus();
1228 }
1229
1230 // ROM safety: validate loaded room content before writing any bytes.
1231 {
1232 zelda3::DungeonValidator validator;
1233 const auto result = validator.ValidateRoom(room);
1234 for (const auto& w : result.warnings) {
1235 LOG_WARN("DungeonEditorV2", "Room 0x%03X validation warning: %s", room_id,
1236 w.c_str());
1237 }
1238 if (!result.is_valid) {
1239 for (const auto& e : result.errors) {
1240 LOG_ERROR("DungeonEditorV2", "Room 0x%03X validation error: %s",
1241 room_id, e.c_str());
1242 }
1243 if (dependencies_.toast_manager) {
1244 dependencies_.toast_manager->Show(
1245 absl::StrFormat(
1246 "Save blocked: room 0x%03X failed validation (%zu error(s))",
1247 room_id, result.errors.size()),
1248 ToastType::kError);
1249 }
1250 return absl::FailedPreconditionError(
1251 absl::StrFormat("Room 0x%03X failed validation", room_id));
1252 }
1253 }
1254
1255 const auto& flags = core::FeatureFlags::get().dungeon;
1256
1257 // A present copy-on-write layout is an explicit capability grant from the
1258 // project manifest. Without it, Room retains the Wave 1 fail-closed behavior
1259 // for shared or over-capacity streams.
1260 std::optional<zelda3::DungeonStreamLayout> object_cow_layout;
1261 std::optional<zelda3::DungeonStreamLayout> sprite_cow_layout;
1262 if (dependencies_.project && dependencies_.project->hack_manifest.loaded()) {
1263 const auto& manifest = dependencies_.project->hack_manifest;
1264 auto load_cow_layout =
1265 [&](core::DungeonStreamType stream_type, bool stream_dirty,
1266 std::optional<zelda3::DungeonStreamLayout>* out) -> absl::Status {
1267 if (!stream_dirty) {
1268 return absl::OkStatus();
1269 }
1270 const auto* manifest_layout =
1271 manifest.GetDungeonStreamLayout(stream_type);
1272 if (manifest_layout == nullptr ||
1273 manifest_layout->strategy !=
1275 return absl::OkStatus();
1276 }
1278 zelda3::DungeonStreamLayout allocator_layout,
1279 core::ToDungeonStreamAllocatorLayout(stream_type, *manifest_layout));
1280 *out = std::move(allocator_layout);
1281 return absl::OkStatus();
1282 };
1283
1284 RETURN_IF_ERROR(load_cow_layout(
1286 flags.kSaveObjects &&
1287 (room.object_stream_dirty() || room.object_stream_header_dirty()),
1288 &object_cow_layout));
1290 flags.kSaveSprites && room.sprites_dirty(),
1291 &sprite_cow_layout));
1292 }
1293
1294 // HACK MANIFEST VALIDATION
1295 if (dependencies_.project && dependencies_.project->hack_manifest.loaded()) {
1296 std::vector<std::pair<uint32_t, uint32_t>> ranges;
1297 const auto& manifest = dependencies_.project->hack_manifest;
1298 const auto& rom_data = rom_->vector();
1299
1300 // 1. Validate Header Range
1301 if (flags.kSaveRoomHeaders && room.header_dirty()) {
1302 if (zelda3::kRoomHeaderPointer + 2 < static_cast<int>(rom_data.size())) {
1303 int header_ptr_table =
1304 (rom_data[zelda3::kRoomHeaderPointer + 2] << 16) |
1305 (rom_data[zelda3::kRoomHeaderPointer + 1] << 8) |
1307 header_ptr_table = yaze::SnesToPc(header_ptr_table);
1308 int table_offset = header_ptr_table + (room_id * 2);
1309
1310 if (table_offset + 1 < static_cast<int>(rom_data.size())) {
1311 int address = (rom_data[zelda3::kRoomHeaderPointerBank] << 16) |
1312 (rom_data[table_offset + 1] << 8) |
1313 rom_data[table_offset];
1314 int header_location = yaze::SnesToPc(address);
1315 ranges.emplace_back(header_location, header_location + 14);
1316 }
1317 }
1318 const int message_address = zelda3::kMessagesIdDungeon + (room_id * 2);
1319 if (message_address >= 0 &&
1320 message_address + 1 < static_cast<int>(rom_data.size())) {
1321 ranges.emplace_back(message_address, message_address + 2);
1322 }
1323 }
1324
1325 // `ranges` are PC offsets (ROM file offsets). The hack manifest is in SNES
1326 // address space (LoROM), so convert before analysis.
1327 const auto write_policy = dependencies_.project->rom_metadata.write_policy;
1329 manifest, write_policy, ranges, absl::StrFormat("room 0x%03X", room_id),
1330 "DungeonEditorV2", dependencies_.toast_manager));
1331 }
1332
1333 const auto validate_stream_ranges =
1334 [&](const std::vector<std::pair<uint32_t, uint32_t>>& ranges)
1335 -> absl::Status {
1336 if (!dependencies_.project ||
1337 !dependencies_.project->hack_manifest.loaded()) {
1338 return absl::OkStatus();
1339 }
1341 dependencies_.project->hack_manifest,
1342 dependencies_.project->rom_metadata.write_policy, ranges,
1343 absl::StrFormat("room 0x%03X", room_id), "DungeonEditorV2",
1344 dependencies_.toast_manager);
1345 };
1346
1347 if (flags.kSaveObjects && room.object_stream_dirty()) {
1348 ASSIGN_OR_RETURN(auto ranges,
1349 room.GetObjectSaveWriteRanges(object_cow_layout.has_value()
1350 ? &*object_cow_layout
1351 : nullptr));
1352 RETURN_IF_ERROR(validate_stream_ranges(ranges));
1353 auto status = room.SaveObjects(
1354 object_cow_layout.has_value() ? &*object_cow_layout : nullptr);
1355 if (!status.ok()) {
1356 LOG_ERROR("DungeonEditorV2", "Failed to save room objects: %s",
1357 status.message().data());
1358 return status;
1359 }
1360 }
1361
1362 if (flags.kSaveObjects && room.object_stream_header_dirty()) {
1363 // Object serialization may just have relocated the stream. Query its
1364 // header destination from the updated ROM immediately before saving it.
1366 auto ranges,
1367 room.GetObjectStreamHeaderSaveWriteRanges(
1368 object_cow_layout.has_value() ? &*object_cow_layout : nullptr));
1369 RETURN_IF_ERROR(validate_stream_ranges(ranges));
1370 auto status = room.SaveObjectStreamHeader(
1371 object_cow_layout.has_value() ? &*object_cow_layout : nullptr);
1372 if (!status.ok()) {
1373 LOG_ERROR("DungeonEditorV2",
1374 "Failed to save room object-stream header: %s",
1375 status.message().data());
1376 return status;
1377 }
1378 }
1379
1380 if (flags.kSaveSprites && room.sprites_dirty()) {
1381 ASSIGN_OR_RETURN(auto ranges,
1382 room.GetSpriteSaveWriteRanges(sprite_cow_layout.has_value()
1383 ? &*sprite_cow_layout
1384 : nullptr));
1385 RETURN_IF_ERROR(validate_stream_ranges(ranges));
1386 auto status = room.SaveSprites(
1387 sprite_cow_layout.has_value() ? &*sprite_cow_layout : nullptr);
1388 if (!status.ok()) {
1389 LOG_ERROR("DungeonEditorV2", "Failed to save room sprites: %s",
1390 status.message().data());
1391 return status;
1392 }
1393 }
1394
1395 if (flags.kSaveRoomHeaders && room.header_dirty()) {
1396 auto status = room.SaveRoomHeader();
1397 if (!status.ok()) {
1398 LOG_ERROR("DungeonEditorV2", "Failed to save room header: %s",
1399 status.message().data());
1400 return status;
1401 }
1402 }
1403
1404 return absl::OkStatus();
1405}
1406
1407void DungeonEditorV2::SaveAllRooms() {
1408 auto status = RunWithSaveTransaction([this]() { return Save(); });
1409 if (status.ok()) {
1410 if (dependencies_.toast_manager) {
1411 dependencies_.toast_manager->Show("Applied loaded rooms to ROM buffer",
1412 ToastType::kSuccess);
1413 }
1414 } else {
1415 LOG_ERROR("DungeonEditorV2", "SaveAllRooms failed: %s",
1416 status.message().data());
1417 if (dependencies_.toast_manager) {
1418 dependencies_.toast_manager->Show(
1419 absl::StrFormat("Save all failed: %s", status.message()),
1420 ToastType::kError);
1421 }
1422 }
1423}
1424
1425} // namespace yaze::editor
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:52
absl::StatusOr< uint8_t > ReadByte(int offset) const
Definition rom.cc:1036
const auto & vector() const
Definition rom.h:217
absl::StatusOr< uint16_t > ReadWord(int offset) const
Definition rom.cc:1044
auto size() const
Definition rom.h:212
bool is_loaded() const
Definition rom.h:199
absl::StatusOr< uint32_t > ReadLong(int offset) const
Definition rom.cc:1051
static Flags & get()
Definition features.h:122
const DungeonStreamLayout * GetDungeonStreamLayout(DungeonStreamType stream) const
Get an explicitly declared dungeon stream layout.
bool loaded() const
Check if the manifest has been loaded.
class MinecartTrackEditorPanel * minecart_track_editor_panel_
absl::Status ApplyRoomLabelEdit(int room_id, std::optional< std::string > label)
std::array< zelda3::RoomEntrance, zelda3::kNumDungeonEntranceSlots > entrances_
absl::Status PreflightPotItemPlacement(int room_id, const std::vector< zelda3::PotItem > &items)
absl::Status ValidateSaveScope(int only_room=-1, bool room_data_only=false) const
absl::StatusOr< std::vector< std::pair< uint32_t, uint32_t > > > PlanPendingRoomStreamRanges(const std::vector< DungeonSelectionEditPlan > &candidates) const
ObjectTileEditorPanel * object_tile_editor_panel_
static absl::Status ValidateRoomHeaderOwnership(const Rom &rom, int room_id)
static bool IsValidRoomId(int room_id)
absl::Status RunWithSaveTransaction(const std::function< absl::Status()> &operation)
std::array< zelda3::DungeonSpawnPoint, zelda3::kNumDungeonSpawnPoints > spawn_points_
std::optional< SaveTransactionSnapshot > save_transaction_snapshot_
std::vector< std::pair< uint32_t, uint32_t > > CollectWriteRanges() const
zelda3::Room * GetIfMaterialized(int room_id)
zelda3::Room * GetIfLoaded(int room_id)
The EditorManager controls the main editor window and manages the various editor classes.
project::YazeProject * project() const
Definition editor.h:370
EditorDependencies dependencies_
Definition editor.h:382
Centralized palette management system.
absl::StatusOr< std::vector< std::pair< uint32_t, uint32_t > > > GetModifiedColorWriteRanges() const
Get exact, coalesced half-open ROM ranges for modified colors. Invalid or unbound addresses return an...
static PaletteManager & Get()
Get the singleton instance.
ValidationResult ValidateRoom(const Room &room)
void set_water_fill_sram_bit_mask(uint8_t mask)
Definition room.h:647
bool custom_collision_dirty() const
Definition room.h:596
const WaterFillZoneMap & water_fill_zone() const
Definition room.h:601
int WaterFillTileCount() const
Definition room.h:642
bool IsLoaded() const
Definition room.h:970
bool pot_items_dirty() const
Definition room.h:431
bool object_stream_dirty() const
Definition room.h:504
bool sprites_dirty() const
Definition room.h:318
bool header_dirty() const
Definition room.h:658
bool object_stream_header_dirty() const
Definition room.h:506
bool has_water_fill_zone() const
Definition room.h:603
auto rom() const
Definition room.h:1082
bool chests_dirty() const
Definition room.h:325
void ClearWaterFillDirty()
Definition room.h:655
bool blocks_dirty() const
Definition room.h:438
bool torches_dirty() const
Definition room.h:435
SaveDirtySnapshot CaptureSaveDirtySnapshot() const
Definition room.h:673
bool water_fill_dirty() const
Definition room.h:654
const std::vector< PotItem > & GetPotItems() const
Definition room.h:429
uint8_t water_fill_sram_bit_mask() const
Definition room.h:644
int id() const
Definition room.h:1012
#define LOG_ERROR(category, format,...)
Definition log.h:140
#define LOG_WARN(category, format,...)
Definition log.h:138
#define LOG_INFO(category, format,...)
Definition log.h:136
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
DungeonStreamType
Dungeon stream kinds with allocator layouts in the hack manifest.
absl::StatusOr< zelda3::DungeonStreamLayout > ToDungeonStreamAllocatorLayout(DungeonStreamType stream_type, const DungeonStreamLayout &manifest_layout)
absl::Status ValidatePotItemManifestConflicts(const project::YazeProject *project, Rom *rom, const DungeonRoomStore &rooms, absl::string_view save_scope, ToastManager *toast_manager)
std::vector< std::pair< uint32_t, uint32_t > > CollectDirtyWaterFillWriteRanges(const Rom *rom, const DungeonRoomStore &rooms)
absl::Status ValidateCustomCollisionManifestConflicts(const project::YazeProject *project, const Rom *rom, const DungeonRoomStore &rooms, absl::string_view save_scope, ToastManager *toast_manager)
absl::StatusOr< std::vector< std::pair< uint32_t, uint32_t > > > CollectDirtyChestWriteRanges(const Rom *rom, const DungeonRoomStore &rooms)
std::vector< std::pair< uint32_t, uint32_t > > CollectDirtyPotItemWriteRanges(const project::YazeProject *project, const Rom *rom, const DungeonRoomStore &rooms)
absl::Status SaveAllPotItemsForProject(const project::YazeProject *project, Rom *rom, DungeonRoomStore &rooms)
absl::Status ValidateDungeonEntranceSavePreflight(const project::YazeProject *project, Rom *rom, const std::array< zelda3::RoomEntrance, zelda3::kNumDungeonEntranceSlots > &entrances, const std::array< zelda3::DungeonSpawnPoint, zelda3::kNumDungeonSpawnPoints > &spawn_points, absl::string_view save_scope, ToastManager *toast_manager)
absl::Status ValidatePaletteManifestConflicts(const project::YazeProject *project, const gfx::PaletteManager &palette_manager, const zelda3::GameData *game_data, absl::string_view save_scope, ToastManager *toast_manager)
std::vector< std::pair< uint32_t, uint32_t > > CollectDirtyCustomCollisionWriteRanges(const Rom *rom, const DungeonRoomStore &rooms)
absl::Status ValidateChestManifestConflicts(const project::YazeProject *project, const zelda3::ChestSavePlan &plan, absl::string_view save_scope, ToastManager *toast_manager)
absl::Status ValidateChestObjectSaveCoupling(const DungeonRoomStore &rooms, int only_room=-1)
absl::Status ValidateWaterFillManifestConflicts(const project::YazeProject *project, const Rom *rom, const DungeonRoomStore &rooms, absl::string_view save_scope, ToastManager *toast_manager)
void ClearRoomLabel(project::YazeProject *project, int room_id)
void SetRoomLabel(project::YazeProject *project, int room_id, const std::string &label)
Editors are the view controllers for the application.
absl::Status ValidateHackManifestSaveConflicts(const core::HackManifest &manifest, project::RomWritePolicy write_policy, const std::vector< std::pair< uint32_t, uint32_t > > &ranges, absl::string_view save_scope, const char *log_tag, ToastManager *toast_manager)
constexpr char kRoomTransferSharedHeaderPayload[]
constexpr int kWaterFillTableEnd
constexpr int kCustomCollisionDataSoftEnd
absl::StatusOr< ChestSavePlan > BuildChestSavePlan(const Rom *rom, int room_count, const std::function< const Room *(int)> &room_lookup)
Definition room.cc:4270
constexpr int kWaterFillTableStart
constexpr int kMessagesIdDungeon
absl::Status NormalizeWaterFillZoneMasks(std::vector< WaterFillZoneEntry > *zones)
absl::StatusOr< std::vector< DungeonEntranceWriteRange > > CollectDirtyRegularDungeonEntranceWriteRanges(const Rom &rom, const std::array< RoomEntrance, kNumDungeonEntranceSlots > &entrances)
absl::Status SaveAllPotItems(Rom *rom, absl::Span< const Room > rooms)
Definition room.cc:4492
absl::Status SaveAllDungeonSpawnPoints(Rom *rom, std::array< DungeonSpawnPoint, kNumDungeonSpawnPoints > &spawn_points, bool dirty_only=true)
absl::Status SaveAllTorches(Rom *rom, absl::Span< const Room > rooms)
Definition room.cc:3332
absl::Status SaveAllPits(Rom *rom)
Definition room.cc:3359
absl::Status SaveAllBlocks(Rom *rom)
Definition room.cc:3544
constexpr int kRoomItemsPointers
constexpr int kCustomCollisionDataPosition
absl::Status RejectUnsupportedDungeonSpawnPointSaves(const std::array< RoomEntrance, kNumDungeonEntranceSlots > &entrances, bool dirty_only=true)
absl::Status SaveAllDungeonEntrances(Rom *rom, std::array< RoomEntrance, kNumDungeonEntranceSlots > &entrances, bool dirty_only=true)
absl::Status ValidateRegularDungeonEntrancesForSave(const Rom &rom, const std::array< RoomEntrance, kNumDungeonEntranceSlots > &entrances, bool dirty_only=true)
constexpr int kNumberOfRooms
absl::Status ApplyChestSavePlan(Rom *rom, const ChestSavePlan &plan, const std::function< const Room *(int)> &room_lookup)
Definition room.cc:4284
constexpr int kRoomHeaderPointer
constexpr int kRoomHeaderPointerBank
constexpr int kCustomCollisionRoomPointers
absl::Status SaveAllCollision(Rom *rom, absl::Span< Room > rooms)
Definition room.cc:3934
absl::Status ValidateDungeonSpawnPointsForSave(const Rom &rom, const std::array< DungeonSpawnPoint, kNumDungeonSpawnPoints > &spawn_points, bool dirty_only=true)
absl::StatusOr< std::vector< WaterFillZoneEntry > > LoadWaterFillTable(Rom *rom)
std::vector< DungeonSpawnPointWriteRange > CollectDirtyDungeonSpawnPointWriteRanges(const std::array< DungeonSpawnPoint, kNumDungeonSpawnPoints > &spawn_points)
absl::Status WriteWaterFillTable(Rom *rom, const std::vector< WaterFillZoneEntry > &zones)
uint32_t SnesToPc(uint32_t addr) noexcept
Definition snes.h:8
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
DungeonPointerEncoding pointer_encoding
struct yaze::core::FeatureFlags::Flags::Dungeon dungeon
std::vector< std::pair< int, zelda3::Room::SaveDirtySnapshot > > room_states
std::array< bool, zelda3::kNumDungeonSpawnPoints > spawn_dirty_states
std::array< bool, zelda3::kNumDungeonEntranceSlots > entrance_dirty_states
project::YazeProject * project
Definition editor.h:174
RomWritePolicy write_policy
Definition project.h:229
Modern project structure with comprehensive settings consolidation.
Definition project.h:290
bool project_opened() const
Definition project.h:538
core::HackManifest hack_manifest
Definition project.h:376
std::vector< std::pair< uint32_t, uint32_t > > write_ranges() const
Definition room.h:1430
PitDamageTable pit_damage_table
Definition game_data.h:114
std::vector< uint16_t > fill_offsets
std::array< uint8_t, 64 *64 > tiles
Definition room.h:223