yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
minecart_track_editor_panel.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <charconv>
5#include <filesystem>
6#include <fstream>
7#include <iterator>
8#include <memory>
9#include <system_error>
10#include <utility>
11#include <vector>
12
13#include "absl/status/status.h"
14#include "absl/strings/ascii.h"
15#include "absl/strings/match.h"
16#include "absl/strings/str_format.h"
17#include "absl/strings/str_split.h"
19#include "imgui/imgui.h"
20#include "imgui/misc/cpp/imgui_stdlib.h"
21#include "util/i18n/tr.h"
22#include "util/macro.h"
23
24#include "app/gui/core/icons.h"
25#include "app/gui/core/input.h"
27#include "util/log.h"
30
31namespace yaze::editor {
32
33namespace {
34constexpr int kTrackSlotCount = static_cast<int>(kMinecartTrackSlotCount);
35constexpr int kDefaultTrackRoom = 0x89;
36constexpr int kDefaultTrackX = 0x1300;
37constexpr int kDefaultTrackY = 0x1100;
38#if defined(__EMSCRIPTEN__)
39constexpr bool kSourcePublishingAvailable = false;
40#else
41constexpr bool kSourcePublishingAvailable = true;
42#endif
43
45 .subject = "minecart track source",
46 .published_file = "published minecart track source",
47};
48
49std::optional<core::MinecartTrackLayout::Source> ProjectSourceIdentity(
50 const project::YazeProject* project) {
51 if (project == nullptr || !project->hack_manifest.loaded()) {
52 return std::nullopt;
53 }
55}
56
57bool IsStrictDescendant(const std::filesystem::path& path,
58 const std::filesystem::path& root) {
59 auto path_it = path.begin();
60 auto root_it = root.begin();
61 for (; root_it != root.end(); ++root_it, ++path_it) {
62 if (path_it == path.end() || *path_it != *root_it) {
63 return false;
64 }
65 }
66 return path_it != path.end();
67}
68
69absl::StatusOr<std::string> ReadSourceFile(const std::filesystem::path& path) {
70 std::ifstream input(path, std::ios::binary);
71 if (!input.is_open()) {
72 return absl::NotFoundError(
73 absl::StrFormat("Could not open minecart source: %s", path.string()));
74 }
75 std::string content{std::istreambuf_iterator<char>(input),
76 std::istreambuf_iterator<char>()};
77 if (!input.good() && !input.eof()) {
78 return absl::DataLossError(
79 absl::StrFormat("Could not read minecart source: %s", path.string()));
80 }
81 return content;
82}
83
84std::string FormatHexList(const std::vector<uint16_t>& values) {
85 std::string out;
86 for (size_t i = 0; i < values.size(); ++i) {
87 if (i > 0) {
88 out += ", ";
89 }
90 out += absl::StrFormat("0x%X", values[i]);
91 }
92 return out;
93}
94
103
104bool HasAnyCustomCollision(const zelda3::CustomCollisionMap& map) {
105 return map.has_data || std::any_of(map.tiles.begin(), map.tiles.end(),
106 [](uint8_t tile) { return tile != 0; });
107}
108
109absl::StatusOr<std::vector<uint16_t>> ParseHexList(const std::string& input) {
110 std::vector<uint16_t> out;
111 const absl::string_view trimmed_input = absl::StripAsciiWhitespace(input);
112 if (trimmed_input.empty()) {
113 return out;
114 }
115
116 for (absl::string_view comma_group : absl::StrSplit(trimmed_input, ',')) {
117 comma_group = absl::StripAsciiWhitespace(comma_group);
118 if (comma_group.empty()) {
119 return absl::InvalidArgumentError("Overlay list contains an empty value");
120 }
121
122 for (absl::string_view token : absl::StrSplit(
123 comma_group, absl::ByAnyChar(" \n\t\r"), absl::SkipEmpty())) {
124 const absl::string_view original_token = token;
125 int base = 10;
126 if (absl::StartsWith(token, "$")) {
127 token.remove_prefix(1);
128 base = 16;
129 } else if (absl::StartsWith(token, "0x") ||
130 absl::StartsWith(token, "0X")) {
131 token.remove_prefix(2);
132 base = 16;
133 }
134 if (token.empty()) {
135 return absl::InvalidArgumentError(absl::StrFormat(
136 "Overlay value is missing digits: %s", original_token));
137 }
138
139 uint32_t value = 0;
140 const auto [end, error] = std::from_chars(
141 token.data(), token.data() + token.size(), value, base);
142 if (error == std::errc::result_out_of_range || value > 0xFFFF) {
143 return absl::OutOfRangeError(absl::StrFormat(
144 "Overlay value is outside 16-bit range: %s", original_token));
145 }
146 if (error != std::errc() || end != token.data() + token.size()) {
147 return absl::InvalidArgumentError(
148 absl::StrFormat("Invalid overlay value: %s", original_token));
149 }
150 out.push_back(static_cast<uint16_t>(value));
151 }
152 }
153 return out;
154}
155} // namespace
156
158 tracks_.clear();
159 loaded_tracks_.clear();
160 source_document_.reset();
162 loaded_source_path_.clear();
163 loaded_source_sha256_.clear();
164 load_attempted_ = false;
165 loaded_ = false;
168 audit_dirty_ = true;
169}
170
172 const std::string current_filepath = project_ ? project_->filepath : "";
173 const auto current_source_identity = ProjectSourceIdentity(project_);
174 if (current_filepath == bound_project_filepath_ &&
175 current_source_identity == bound_source_identity_) {
176 return absl::OkStatus();
177 }
178 if (HasUnpublishedChanges()) {
179 return absl::FailedPreconditionError(
180 "Project descriptor moved or minecart source changed; discard "
181 "minecart track drafts before rebinding the source");
182 }
183
184 bound_project_filepath_ = current_filepath;
185 bound_source_identity_ = current_source_identity;
187 overlay_inputs_model_.reset();
189 return absl::OkStatus();
190}
191
193 project::YazeProject* project) {
194 const std::string next_filepath = project ? project->filepath : "";
195 const auto next_source_identity = ProjectSourceIdentity(project);
196 if (project_ == project && bound_project_filepath_ == next_filepath &&
197 bound_source_identity_ == next_source_identity) {
198 return absl::OkStatus();
199 }
200 if (HasUnpublishedChanges()) {
201 return absl::FailedPreconditionError(
202 "Discard minecart track drafts before changing projects");
203 }
204
205 project_ = project;
206 bound_project_filepath_ = next_filepath;
207 bound_source_identity_ = next_source_identity;
209 overlay_inputs_model_.reset();
211 return absl::OkStatus();
212}
213
215 project::YazeProject* project) {
216 const absl::Status status = SetProject(project);
217 if (!status.ok()) {
218 status_message_ = std::string(status.message());
219 show_success_ = false;
220 return status;
221 }
222
223 // Project descriptors are reapplied into stable session storage. Refresh a
224 // same-pointer binding only when the model changed; otherwise preserve text
225 // currently being edited until it can be validated and committed.
226 if (project != nullptr && (!overlay_inputs_model_.has_value() ||
227 !OverlaySettingsEqual(*overlay_inputs_model_,
228 project->dungeon_overlay))) {
232 }
233 return absl::OkStatus();
234}
235
237 if (project_ == nullptr || !project_->hack_manifest.loaded()) {
238 return absl::FailedPreconditionError(
239 "A loaded hack manifest is required to publish minecart tracks");
240 }
241 const auto& current_source =
243 if (!current_source.has_value()) {
244 return absl::FailedPreconditionError(
245 "Hack manifest does not define minecart_tracks.source");
246 }
247 if (!loaded_source_identity_.has_value() ||
248 *current_source != *loaded_source_identity_) {
249 return absl::FailedPreconditionError(
250 "Hack manifest minecart_tracks.source changed after the tracks were "
251 "loaded; drafts were kept");
252 }
253 return absl::OkStatus();
254}
255
256absl::StatusOr<std::filesystem::path>
258 if (project_ != nullptr && project_->filepath != bound_project_filepath_) {
259 return absl::FailedPreconditionError(
260 "Project descriptor moved; reload minecart tracks to rebind the "
261 "source");
262 }
263 if (project_ == nullptr || bound_project_filepath_.empty()) {
264 return absl::FailedPreconditionError(
265 "An open project descriptor is required for minecart tracks");
266 }
267 if (!project_->hack_manifest.loaded()) {
268 return absl::FailedPreconditionError(
269 "A loaded hack manifest is required for minecart tracks");
270 }
271 const auto& source = project_->hack_manifest.minecart_track_layout().source;
272 if (!source.has_value()) {
273 return absl::FailedPreconditionError(
274 "Hack manifest does not define minecart_tracks.source");
275 }
276
277 std::error_code ec;
278 std::filesystem::path descriptor_path(bound_project_filepath_);
279 if (descriptor_path.is_relative()) {
280 descriptor_path = std::filesystem::absolute(descriptor_path, ec);
281 if (ec) {
282 return absl::InvalidArgumentError(absl::StrFormat(
283 "Could not resolve project descriptor path: %s", ec.message()));
284 }
285 }
286
287 const std::filesystem::path project_root =
288 std::filesystem::weakly_canonical(descriptor_path.parent_path(), ec);
289 if (ec || project_root.empty() ||
290 !std::filesystem::is_directory(project_root, ec) || ec) {
291 return absl::InvalidArgumentError(
292 "Project descriptor parent is not an existing directory");
293 }
294
295 const std::filesystem::path candidate =
296 (project_root / source->path).lexically_normal();
297 const std::filesystem::file_status candidate_status =
298 std::filesystem::symlink_status(candidate, ec);
299 if (ec) {
300 return absl::NotFoundError(
301 absl::StrFormat("Minecart source not found: %s", candidate.string()));
302 }
303 if (std::filesystem::is_symlink(candidate_status)) {
304 return absl::PermissionDeniedError(
305 "Minecart source may not be a symbolic link");
306 }
307 const std::filesystem::path resolved =
308 std::filesystem::canonical(candidate, ec);
309 if (ec) {
310 return absl::NotFoundError(
311 absl::StrFormat("Minecart source not found: %s", candidate.string()));
312 }
313 if (!IsStrictDescendant(resolved, project_root)) {
314 return absl::PermissionDeniedError(
315 "Minecart source resolves outside the project root");
316 }
317 if (!std::filesystem::is_regular_file(resolved, ec) || ec) {
318 return absl::InvalidArgumentError(
319 "Minecart source must be an existing regular file");
320 }
321 return resolved;
322}
323
327
343
345 const absl::StatusOr<bool> committed = CommitOverlayInputsForSave();
346 return committed.ok() ? absl::OkStatus() : committed.status();
347}
348
349absl::Status MinecartTrackEditorPanel::UpdateTrack(size_t track_index,
350 const MinecartTrack& track) {
351 if (!loaded_) {
352 return absl::FailedPreconditionError("Minecart tracks are not loaded");
353 }
354 if (track_index >= tracks_.size()) {
355 return absl::InvalidArgumentError("Minecart track index is out of range");
356 }
357 MinecartTrack updated = track;
358 updated.id = static_cast<int>(track_index);
359 tracks_[track_index] = updated;
360 audit_dirty_ = true;
361 return absl::OkStatus();
362}
363
365 if (!loaded_) {
366 return absl::FailedPreconditionError("Minecart tracks are not loaded");
367 }
370 audit_dirty_ = true;
371 return absl::OkStatus();
372}
373
375 const absl::Status binding_status = RefreshProjectBinding();
376 if (!binding_status.ok()) {
377 return binding_status;
378 }
379 if (HasUnpublishedChanges()) {
380 return absl::FailedPreconditionError(
381 "Discard minecart track drafts before reloading the source");
382 }
383 return LoadTracks();
384}
385
403
411
413 std::string& input,
414 OverlayListMember member) {
415 if (ImGui::InputText(label, &input)) {
416 const absl::Status draft_status = NotifyProjectDraftChanged();
417 if (!draft_status.ok()) {
418 input = FormatHexList(project_->dungeon_overlay.*member);
420 absl::StrFormat("Overlay draft rejected: %s", draft_status.message());
421 show_success_ = false;
422 return false;
423 }
424 }
425 if (ImGui::IsItemDeactivatedAfterEdit()) {
426 const absl::StatusOr<bool> changed = CommitOverlayList(input, member);
427 return changed.ok() && *changed;
428 }
429 return false;
430}
431
433 std::string& input, OverlayListMember member) {
434 if (project_ == nullptr) {
435 const absl::Status status =
436 absl::FailedPreconditionError("No project is bound to the panel");
437 status_message_ = std::string(status.message());
438 show_success_ = false;
439 return status;
440 }
441
442 const auto parsed_or = ParseHexList(input);
443 if (!parsed_or.ok()) {
444 status_message_ = absl::StrFormat("Overlay update rejected: %s",
445 parsed_or.status().message());
446 show_success_ = false;
447 return parsed_or.status();
448 }
449
451 std::vector<uint16_t>& candidate_target = candidate.*member;
452 if (*parsed_or == candidate_target) {
453 input = FormatHexList(*parsed_or);
455 status_message_.clear();
456 show_success_ = false;
457 return false;
458 }
459
460 candidate_target = *parsed_or;
461 const absl::Status notify_status = NotifyProjectChanged(candidate);
462 if (!notify_status.ok()) {
463 input = FormatHexList(project_->dungeon_overlay.*member);
465 absl::StrFormat("Overlay update rejected: %s", notify_status.message());
466 show_success_ = false;
467 return notify_status;
468 }
469
470 project_->dungeon_overlay = std::move(candidate);
471 input = FormatHexList(project_->dungeon_overlay.*member);
474 audit_dirty_ = true;
475 status_message_ = "Overlay settings updated; save the project to persist.";
476 show_success_ = true;
477 return true;
478}
479
481 if (project_ == nullptr) {
482 return absl::FailedPreconditionError("No project is bound to the panel");
483 }
484
487 auto parse_field = [](const char* field_name, const std::string& input,
488 std::vector<uint16_t>* target) -> absl::Status {
489 const auto parsed_or = ParseHexList(input);
490 if (!parsed_or.ok()) {
491 return absl::Status(
492 parsed_or.status().code(),
493 absl::StrFormat("%s: %s", field_name, parsed_or.status().message()));
494 }
495 *target = *parsed_or;
496 return absl::OkStatus();
497 };
498
499 absl::Status parse_status = parse_field(
500 "Track Tiles", overlay_track_tiles_input_, &candidate.track_tiles);
501 if (parse_status.ok()) {
502 parse_status = parse_field("Stop Tiles", overlay_track_stop_tiles_input_,
503 &candidate.track_stop_tiles);
504 }
505 if (parse_status.ok()) {
506 parse_status =
507 parse_field("Switch Tiles", overlay_track_switch_tiles_input_,
508 &candidate.track_switch_tiles);
509 }
510 if (parse_status.ok()) {
511 parse_status =
512 parse_field("Track Object IDs", overlay_track_object_ids_input_,
513 &candidate.track_object_ids);
514 }
515 if (parse_status.ok()) {
516 parse_status =
517 parse_field("Minecart Sprite IDs", overlay_minecart_sprite_ids_input_,
518 &candidate.minecart_sprite_ids);
519 }
520 if (!parse_status.ok()) {
522 absl::StrFormat("Project save blocked: %s", parse_status.message());
523 show_success_ = false;
524 return parse_status;
525 }
526
527 auto normalize_inputs = [this]() {
529 FormatHexList(project_->dungeon_overlay.track_tiles);
539 };
540
541 if (OverlaySettingsEqual(candidate, project_->dungeon_overlay)) {
542 normalize_inputs();
543 return false;
544 }
545
546 const absl::Status notify_status = NotifyProjectChanged(candidate);
547 if (!notify_status.ok()) {
549 absl::StrFormat("Project save blocked: %s", notify_status.message());
550 show_success_ = false;
551 return notify_status;
552 }
553
554 project_->dungeon_overlay = std::move(candidate);
555 normalize_inputs();
557 audit_dirty_ = true;
558 return true;
559}
560
562 if (project_ == nullptr) {
563 const absl::Status status =
564 absl::FailedPreconditionError("No project is bound to the panel");
565 status_message_ = std::string(status.message());
566 show_success_ = false;
567 return status;
568 }
569
571 const bool changed =
572 !overlay.track_tiles.empty() || !overlay.track_stop_tiles.empty() ||
573 !overlay.track_switch_tiles.empty() ||
574 !overlay.track_object_ids.empty() || !overlay.minecart_sprite_ids.empty();
575 if (!changed) {
578 status_message_.clear();
579 show_success_ = false;
580 return false;
581 }
582
583 overlay.track_tiles.clear();
584 overlay.track_stop_tiles.clear();
585 overlay.track_switch_tiles.clear();
586 overlay.track_object_ids.clear();
587 overlay.minecart_sprite_ids.clear();
588 const absl::Status notify_status = NotifyProjectChanged(overlay);
589 if (!notify_status.ok()) {
591 absl::StrFormat("Overlay reset rejected: %s", notify_status.message());
592 show_success_ = false;
593 return notify_status;
594 }
595
596 project_->dungeon_overlay = std::move(overlay);
600 audit_dirty_ = true;
601 status_message_ = "Overlay settings reset; save the project to persist.";
602 show_success_ = true;
603 return true;
604}
605
607 const project::DungeonOverlaySettings& overlay) {
608 if (project_ == nullptr) {
609 return absl::FailedPreconditionError("No project is bound to the panel");
610 }
612 return absl::FailedPreconditionError(
613 "Project change tracking is unavailable for minecart overlays");
614 }
615 return project_changed_callback_(overlay);
616}
617
619 if (project_ == nullptr) {
620 return absl::FailedPreconditionError("No project is bound to the panel");
621 }
623 return absl::FailedPreconditionError(
624 "Project draft tracking is unavailable for minecart overlays");
625 }
627}
628
630 if (project_ == nullptr || !project_->project_opened()) {
631 return absl::FailedPreconditionError("No open project to save");
632 }
634 return absl::FailedPreconditionError(
635 "Project save is unavailable outside the editor manager");
636 }
637 const absl::StatusOr<bool> committed = CommitOverlayInputsForSave();
638 if (!committed.ok()) {
639 return committed.status();
640 }
641 return project_save_callback_();
642}
643
645 if (!project_) {
646 return;
647 }
648
650
651 if (!ImGui::CollapsingHeader(ICON_MD_TUNE " Advanced")) {
652 return;
653 }
654
655 ImGui::TextDisabled(tr("Advanced collision detection IDs and tile values."));
656 ImGui::TextDisabled(tr("Empty list = defaults. Use hex (0xB0) or decimal."));
657 ImGui::TextDisabled(
658 tr("Defaults: Track 0xB0-0xBE | Stop 0xB7-0xBA | Switch 0xD0-0xD3 | "
659 "Track Obj 0x31 | Cart Sprite 0xA3"));
660
661 bool changed = false;
662 changed |= UpdateOverlayList("Track Tiles", overlay_track_tiles_input_,
664 changed |=
667 changed |=
670 changed |=
673 changed |= UpdateOverlayList(
674 "Minecart Sprite IDs", overlay_minecart_sprite_ids_input_,
676
677 if (ImGui::Button(tr("Reset Overlay Defaults"))) {
678 const absl::StatusOr<bool> reset = ResetOverlaySettings();
679 changed |= reset.ok() && *reset;
680 }
681
682 if (changed) {
683 ImGui::TextDisabled(tr("Remember to save the project to persist changes."));
684 }
685}
686
687const std::vector<MinecartTrack>& MinecartTrackEditorPanel::GetTracks() {
688 const absl::Status binding_status = RefreshProjectBinding();
689 if (!binding_status.ok()) {
690 status_message_ = std::string(binding_status.message());
691 show_success_ = false;
692 return tracks_;
693 }
694 if (!load_attempted_) {
695 const absl::Status status = LoadTracks();
696 if (!status.ok()) {
697 status_message_ = std::string(status.message());
698 show_success_ = false;
699 }
700 }
701 return tracks_;
702}
703
705 uint16_t camera_x,
706 uint16_t camera_y) {
708 picking_track_index_ < static_cast<int>(tracks_.size())) {
709 tracks_[picking_track_index_].room_id = room_id;
710 tracks_[picking_track_index_].start_x = camera_x;
711 tracks_[picking_track_index_].start_y = camera_y;
712
713 last_picked_x_ = camera_x;
714 last_picked_y_ = camera_y;
715 has_picked_coords_ = true;
716 audit_dirty_ = true;
717
719 absl::StrFormat("Track %d: Set to Room $%04X, Pos ($%04X, $%04X)",
720 picking_track_index_, room_id, camera_x, camera_y);
721 show_success_ = true;
722 }
723
724 // Exit picking mode
725 picking_mode_ = false;
727}
728
730 picking_mode_ = true;
731 picking_track_index_ = track_index;
732 status_message_ = absl::StrFormat(
733 "Click on the dungeon canvas to set Track %d position", track_index);
734 show_success_ = false;
735}
736
742
744 const MinecartTrack& track) const {
745 return track.room_id == kDefaultTrackRoom &&
746 track.start_x == kDefaultTrackX && track.start_y == kDefaultTrackY;
747}
748
749void MinecartTrackEditorPanel::RebuildAuditCache(bool include_unmaterialized) {
750 room_audit_.clear();
751 route_usage_rooms_.clear();
752 route_slot_used_.assign(kTrackSlotCount, false);
753
754 if (!rooms_) {
755 audit_dirty_ = false;
756 return;
757 }
758
759 std::array<bool, 256> track_tiles{};
760 std::array<bool, 256> stop_tiles{};
761 std::array<bool, 256> switch_tiles{};
762 auto apply_list = [](std::array<bool, 256>& dest,
763 const std::vector<uint16_t>& values) {
764 dest.fill(false);
765 for (uint16_t value : values) {
766 if (value < dest.size()) {
767 dest[value] = true;
768 }
769 }
770 };
771
772 if (project_ && !project_->dungeon_overlay.track_tiles.empty()) {
773 apply_list(track_tiles, project_->dungeon_overlay.track_tiles);
774 } else {
775 std::vector<uint16_t> default_track_tiles;
776 for (uint16_t tile = 0xB0; tile <= 0xBE; ++tile) {
777 default_track_tiles.push_back(tile);
778 }
779 apply_list(track_tiles, default_track_tiles);
780 }
781
783 apply_list(stop_tiles, project_->dungeon_overlay.track_stop_tiles);
784 } else {
785 apply_list(stop_tiles, {0xB7, 0xB8, 0xB9, 0xBA});
786 }
787
789 apply_list(switch_tiles, project_->dungeon_overlay.track_switch_tiles);
790 } else {
791 apply_list(switch_tiles, {0xD0, 0xD1, 0xD2, 0xD3});
792 }
793
794 std::vector<uint16_t> track_object_ids = {0x31};
795 std::vector<uint16_t> minecart_sprite_ids = {0xA3};
796 if (project_) {
798 track_object_ids = project_->dungeon_overlay.track_object_ids;
799 }
801 minecart_sprite_ids = project_->dungeon_overlay.minecart_sprite_ids;
802 }
803 }
804
805 std::unordered_map<int, bool> track_object_id_map;
806 for (uint16_t id : track_object_ids) {
807 track_object_id_map[static_cast<int>(id)] = true;
808 }
809 std::unordered_map<int, bool> minecart_sprite_id_map;
810 for (uint16_t id : minecart_sprite_ids) {
811 minecart_sprite_id_map[static_cast<int>(id)] = true;
812 }
813
814 auto audit_room = [&](int room_id, zelda3::Room& room) {
815 RoomTrackAudit audit;
816
817 room.EnsureObjectsLoaded();
818 room.EnsureSpritesLoaded();
819
820 std::array<bool, kTrackSlotCount> seen_subtype{};
821
822 for (const auto& obj : room.GetTileObjects()) {
823 if (!track_object_id_map[static_cast<int>(obj.id_)]) {
824 continue;
825 }
826 int subtype = obj.size_ & 0x1F;
827 if (zelda3::IsMinecartTrackGraphicsSubtype(obj.id_, subtype) &&
828 subtype >= 0 && subtype < kTrackSlotCount) {
829 if (!seen_subtype[static_cast<size_t>(subtype)]) {
830 seen_subtype[static_cast<size_t>(subtype)] = true;
831 audit.track_subtypes.push_back(subtype);
832 }
833 }
834 }
835
836 std::unordered_map<int, bool> stop_positions;
837 const auto& collision = room.custom_collision();
838 audit.has_any_custom_collision = HasAnyCustomCollision(collision);
839 if (audit.has_any_custom_collision) {
840 const auto& map = collision.tiles;
841 for (int y = 0; y < 64; ++y) {
842 for (int x = 0; x < 64; ++x) {
843 uint8_t tile = map[static_cast<size_t>(y * 64 + x)];
844 if (track_tiles[tile] || stop_tiles[tile] || switch_tiles[tile]) {
845 audit.has_track_collision = true;
846 }
847 if (stop_tiles[tile]) {
848 audit.has_stop_tiles = true;
849 stop_positions[y * 64 + x] = true;
850 }
851 }
852 }
853 }
854
855 std::array<bool, kTrackSlotCount> seen_route_slot{};
856 for (const auto& sprite : room.GetSprites()) {
857 if (!minecart_sprite_id_map[static_cast<int>(sprite.id())]) {
858 continue;
859 }
860 audit.has_minecart_sprite = true;
861 const int route_slot = sprite.subtype();
862 if (route_slot >= 0 && route_slot < kTrackSlotCount &&
863 !seen_route_slot[static_cast<size_t>(route_slot)]) {
864 seen_route_slot[static_cast<size_t>(route_slot)] = true;
865 route_slot_used_[static_cast<size_t>(route_slot)] = true;
866 route_usage_rooms_[route_slot].push_back(room_id);
867 audit.route_slots.push_back(route_slot);
868 }
869 int tile_x = sprite.x() * 2;
870 int tile_y = sprite.y() * 2;
871 if (tile_x >= 0 && tile_x < 64 && tile_y >= 0 && tile_y < 64) {
872 int idx = tile_y * 64 + tile_x;
873 if (stop_positions[idx]) {
874 audit.has_minecart_on_stop = true;
875 }
876 }
877 }
878
879 if (audit.has_track_collision || !audit.track_subtypes.empty() ||
880 audit.has_minecart_sprite) {
881 room_audit_[room_id] = audit;
882 }
883 };
884
885 // Inspect every room. Materialized rooms are the authority for unsaved
886 // editor changes; unopened rooms are parsed into temporary models so a
887 // global audit does not depend on which room tabs happen to be open.
888 for (int room_id = 0; room_id < static_cast<int>(rooms_->size()); ++room_id) {
889 if (auto* room = rooms_->GetIfMaterialized(room_id)) {
890 audit_room(room_id, *room);
891 continue;
892 }
893 if (!include_unmaterialized) {
894 continue;
895 }
896 if (rooms_->rom() == nullptr || !rooms_->rom()->is_loaded()) {
897 continue;
898 }
899 zelda3::Room room(room_id, rooms_->rom(), rooms_->game_data());
900 audit_room(room_id, room);
901 }
902
903 audit_dirty_ = false;
904 audit_includes_all_rooms_ = include_unmaterialized;
905}
906
907absl::StatusOr<zelda3::GeneratorOptions>
910 if (project_ == nullptr ||
912 return options;
913 }
914 if (project_->dungeon_overlay.track_object_ids.size() != 1) {
915 return absl::FailedPreconditionError(
916 "Collision preview requires exactly one configured track object ID");
917 }
919 return options;
920}
921
923 const std::vector<int>& room_ids) {
924 if (rooms_ == nullptr) {
925 return absl::FailedPreconditionError("Dungeon rooms are unavailable");
926 }
927 if (room_ids.empty()) {
928 return absl::InvalidArgumentError("No eligible rooms to preview");
929 }
930
931 ASSIGN_OR_RETURN(const auto options, ResolveGeneratorOptions());
932 std::vector<int> sorted_room_ids = room_ids;
933 std::sort(sorted_room_ids.begin(), sorted_room_ids.end());
934 if (std::adjacent_find(sorted_room_ids.begin(), sorted_room_ids.end()) !=
935 sorted_room_ids.end()) {
936 return absl::InvalidArgumentError(
937 "Collision preview contains a duplicate room ID");
938 }
939
940 std::vector<zelda3::TrackCollisionResult> preview;
941 preview.reserve(sorted_room_ids.size());
942 for (int room_id : sorted_room_ids) {
943 if (room_id < 0 || room_id >= static_cast<int>(rooms_->size())) {
944 return absl::OutOfRangeError(absl::StrFormat(
945 "Collision preview room 0x%03X is out of range", room_id));
946 }
947 auto* room = rooms_->GetIfMaterialized(room_id);
948 if (room == nullptr) {
949 if (rooms_->rom() == nullptr || !rooms_->rom()->is_loaded()) {
950 return absl::FailedPreconditionError(absl::StrFormat(
951 "Collision preview room 0x%03X cannot be loaded without a ROM",
952 room_id));
953 }
954 auto& materialized = (*rooms_)[room_id];
955 materialized = zelda3::LoadRoomFromRom(rooms_->rom(), room_id);
956 materialized.SetGameData(rooms_->game_data());
957 room = &materialized;
958 }
959 room->EnsureObjectsLoaded();
960 if (HasAnyCustomCollision(room->custom_collision())) {
961 return absl::FailedPreconditionError(absl::StrFormat(
962 "Room 0x%03X already has custom collision; generation will not "
963 "replace it",
964 room_id));
965 }
966
967 ASSIGN_OR_RETURN(auto generated,
968 zelda3::GenerateTrackCollision(room, options));
969 generated.room_id = room_id;
970 if (!generated.collision_map.has_data || generated.tiles_generated <= 0) {
971 return absl::FailedPreconditionError(absl::StrFormat(
972 "Room 0x%03X has no supported minecart track pieces", room_id));
973 }
974 preview.push_back(std::move(generated));
975 }
976
978 collision_preview_ = std::move(preview);
979 return absl::OkStatus();
980}
981
983 RebuildAuditCache(/*include_unmaterialized=*/true);
984 std::vector<int> room_ids;
985 for (const auto& [room_id, audit] : room_audit_) {
986 if (!audit.track_subtypes.empty() && !audit.has_any_custom_collision) {
987 room_ids.push_back(room_id);
988 }
989 }
990 return BuildCollisionPreview(room_ids);
991}
992
994 if (collision_preview_.empty()) {
995 return absl::FailedPreconditionError("No collision preview to apply");
996 }
998 return absl::FailedPreconditionError(
999 "Minecart collision apply is unavailable outside the dungeon editor");
1000 }
1004 audit_dirty_ = true;
1005 return absl::OkStatus();
1006}
1007
1012
1014 if (project_ == nullptr) {
1015 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1016 tr("Open a project to edit minecart tracks."));
1017 return;
1018 }
1019
1020 const absl::Status binding_status = RefreshProjectBinding();
1021 if (!binding_status.ok()) {
1022 status_message_ = std::string(binding_status.message());
1023 show_success_ = false;
1024 }
1025 if (bound_project_filepath_.empty()) {
1026 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1027 tr("Open a project to edit minecart tracks."));
1028 return;
1029 }
1030
1031 if (!load_attempted_) {
1032 const absl::Status status = LoadTracks();
1033 if (!status.ok()) {
1034 status_message_ = std::string(status.message());
1035 show_success_ = false;
1036 }
1037 }
1038
1039 if (audit_dirty_) {
1041 }
1042
1043 ImGui::Text(tr("Minecart Track Editor"));
1044 if (picking_mode_) {
1045 if (ImGui::Button(ICON_MD_CANCEL " Cancel Pick")) {
1047 }
1048 ImGui::SameLine();
1049 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f),
1050 ICON_MD_MY_LOCATION " Picking for Track %d...",
1052 }
1053 if (!ImGui::BeginTabBar("##MinecartTasks")) {
1054 return;
1055 }
1056
1057 if (ImGui::BeginTabItem(tr("Routes"))) {
1058 ImGui::TextDisabled(
1059 tr("Edit route start slots and publish the manifest-owned ASM "
1060 "source."));
1061#if defined(__EMSCRIPTEN__)
1062 ImGui::TextDisabled(
1063 tr("Source publishing is unavailable in browser builds; drafts are "
1064 "retained."));
1065#endif
1066 const bool has_unpublished_changes = HasUnpublishedChanges();
1067 const bool can_publish =
1068 has_unpublished_changes && kSourcePublishingAvailable;
1069 if (!can_publish) {
1070 ImGui::BeginDisabled();
1071 }
1072 if (ImGui::Button(ICON_MD_SAVE " Publish Tracks")) {
1073 const absl::Status status = SaveTracks();
1075 status.ok()
1076 ? "Minecart ASM source published only. Save pending dungeon/ROM "
1077 "edits to the development ROM, rebuild the patched ROM, then "
1078 "reopen/reload the patched ROM in Yaze before testing."
1079 : std::string(status.message());
1080 show_success_ = status.ok();
1081 }
1082 if (!can_publish) {
1083 ImGui::EndDisabled();
1084 }
1085 ImGui::SameLine();
1086 if (!has_unpublished_changes) {
1087 ImGui::BeginDisabled();
1088 }
1089 if (ImGui::Button(ICON_MD_RESTORE " Discard Drafts")) {
1090 const absl::Status status = DiscardUnpublishedChanges();
1091 status_message_ = status.ok() ? "Minecart track drafts discarded."
1092 : std::string(status.message());
1093 show_success_ = status.ok();
1094 }
1095 if (!has_unpublished_changes) {
1096 ImGui::EndDisabled();
1097 }
1098 ImGui::SameLine();
1099 if (ImGui::Button(ICON_MD_REFRESH " Reload Source")) {
1100 const absl::Status status = ReloadTracks();
1101 status_message_ = status.ok() ? "Minecart track source reloaded."
1102 : std::string(status.message());
1103 show_success_ = status.ok();
1104 }
1105 ImGui::SameLine();
1106 const bool can_save_project =
1108 if (!can_save_project) {
1109 ImGui::BeginDisabled();
1110 }
1111 if (ImGui::Button(ICON_MD_SAVE " Save Project")) {
1112 auto status = SaveProjectSettings();
1113 if (status.ok()) {
1114 status_message_ = has_unpublished_changes
1115 ? "Project saved; minecart track drafts remain "
1116 "unsaved."
1117 : "Project saved.";
1118 show_success_ = true;
1119 } else {
1121 absl::StrFormat("Project save failed: %s", status.message());
1122 show_success_ = false;
1123 }
1124 }
1125 if (!can_save_project) {
1126 ImGui::EndDisabled();
1127 }
1128
1129 ImGui::Separator();
1130
1131 // Coordinate format help
1132 ImGui::TextDisabled(tr(
1133 "Camera coordinates use $1XXX format (base $1000 + room offset + local "
1134 "position)"));
1135 ImGui::TextDisabled(
1136 tr("Hover over dungeon canvas to see coordinates, or click 'Pick' "
1137 "button."));
1138 ImGui::Separator();
1139
1140 if (ImGui::BeginTable("TracksTable", 7,
1141 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
1142 ImGuiTableFlags_Resizable)) {
1143 ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 30.0f);
1144 ImGui::TableSetupColumn("Room ID", ImGuiTableColumnFlags_WidthFixed,
1145 80.0f);
1146 ImGui::TableSetupColumn("Camera X", ImGuiTableColumnFlags_WidthFixed,
1147 80.0f);
1148 ImGui::TableSetupColumn("Camera Y", ImGuiTableColumnFlags_WidthFixed,
1149 80.0f);
1150 ImGui::TableSetupColumn("Pick", ImGuiTableColumnFlags_WidthFixed, 50.0f);
1151 ImGui::TableSetupColumn("Go", ImGuiTableColumnFlags_WidthFixed, 40.0f);
1152 ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed,
1153 60.0f);
1154 ImGui::TableHeadersRow();
1155
1156 for (auto& track : tracks_) {
1157 ImGui::TableNextRow();
1158
1159 const bool is_default = IsDefaultTrack(track);
1160 const bool used_in_rooms =
1161 track.id >= 0 &&
1162 track.id < static_cast<int>(route_slot_used_.size()) &&
1163 route_slot_used_[track.id];
1164 const bool missing_start = used_in_rooms && is_default;
1165
1166 if (missing_start) {
1167 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1168 IM_COL32(120, 40, 40, 120));
1169 } else if (is_default) {
1170 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1171 IM_COL32(60, 60, 60, 80));
1172 }
1173
1174 // Highlight the row being picked
1175 if (picking_mode_ && track.id == picking_track_index_) {
1176 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1177 IM_COL32(80, 80, 0, 100));
1178 }
1179
1180 ImGui::TableNextColumn();
1181 ImGui::Text("%d", track.id);
1182
1183 ImGui::TableNextColumn();
1184 uint16_t room_id = static_cast<uint16_t>(track.room_id);
1186 absl::StrFormat("##Room%d", track.id).c_str(), &room_id,
1187 60.0f)) {
1188 track.room_id = room_id;
1189 audit_dirty_ = true;
1190 }
1191
1192 ImGui::TableNextColumn();
1193 uint16_t start_x = static_cast<uint16_t>(track.start_x);
1195 absl::StrFormat("##StartX%d", track.id).c_str(), &start_x,
1196 60.0f)) {
1197 track.start_x = start_x;
1198 audit_dirty_ = true;
1199 }
1200
1201 ImGui::TableNextColumn();
1202 uint16_t start_y = static_cast<uint16_t>(track.start_y);
1204 absl::StrFormat("##StartY%d", track.id).c_str(), &start_y,
1205 60.0f)) {
1206 track.start_y = start_y;
1207 audit_dirty_ = true;
1208 }
1209
1210 // Pick button to select coordinates from canvas
1211 ImGui::TableNextColumn();
1212 ImGui::PushID(track.id);
1213 bool is_picking_this =
1214 picking_mode_ && picking_track_index_ == track.id;
1215 {
1216 std::optional<gui::StyleColorGuard> pick_guard;
1217 if (is_picking_this) {
1218 pick_guard.emplace(ImGuiCol_Button, ImVec4(0.8f, 0.6f, 0.0f, 1.0f));
1219 }
1220 if (ImGui::SmallButton(ICON_MD_MY_LOCATION)) {
1221 if (is_picking_this) {
1223 } else {
1224 StartCoordinatePicking(track.id);
1225 }
1226 }
1227 }
1228 if (ImGui::IsItemHovered()) {
1229 ImGui::SetTooltip(is_picking_this ? "Cancel picking"
1230 : "Pick coordinates from canvas");
1231 }
1232 ImGui::PopID();
1233
1234 // Go to room button
1235 ImGui::TableNextColumn();
1236 ImGui::PushID(track.id + 1000);
1237 if (ImGui::SmallButton(ICON_MD_ARROW_FORWARD)) {
1239 room_navigation_callback_(track.room_id);
1240 }
1241 }
1242 if (ImGui::IsItemHovered()) {
1243 ImGui::SetTooltip(tr("Navigate to room $%04X"), track.room_id);
1244 }
1245 ImGui::PopID();
1246
1247 // Status column
1248 ImGui::TableNextColumn();
1249 if (missing_start) {
1250 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1252 } else if (is_default) {
1253 ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), ICON_MD_INFO);
1254 } else if (used_in_rooms) {
1255 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1257 } else {
1258 ImGui::Text("-");
1259 }
1260
1261 if (ImGui::IsItemHovered()) {
1262 ImGui::BeginTooltip();
1263 if (missing_start) {
1264 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1265 tr("Referenced by a cart but still default"));
1266 } else if (is_default) {
1267 ImGui::Text(tr("Default filler slot"));
1268 } else if (used_in_rooms) {
1269 ImGui::Text(tr("Referenced by a minecart sprite"));
1270 } else {
1271 ImGui::Text(tr("No route reference detected"));
1272 }
1273
1274 auto rooms_it = route_usage_rooms_.find(track.id);
1275 if (rooms_it != route_usage_rooms_.end()) {
1276 ImGui::Separator();
1277 ImGui::Text(tr("Rooms:"));
1278 for (int room_id : rooms_it->second) {
1279 ImGui::BulletText(tr("0x%03X"), room_id);
1280 }
1281 }
1282 ImGui::EndTooltip();
1283 }
1284 }
1285
1286 ImGui::EndTable();
1287 }
1288
1289 // Summary + room audit
1290 int default_count = 0;
1291 int used_count = 0;
1292 int missing_start_count = 0;
1293 for (const auto& track : tracks_) {
1294 bool is_default = IsDefaultTrack(track);
1295 bool used_in_rooms =
1296 track.id >= 0 &&
1297 track.id < static_cast<int>(route_slot_used_.size()) &&
1298 route_slot_used_[track.id];
1299 if (is_default) {
1300 default_count++;
1301 }
1302 if (used_in_rooms) {
1303 used_count++;
1304 }
1305 if (used_in_rooms && is_default) {
1306 missing_start_count++;
1307 }
1308 }
1309
1310 ImGui::Separator();
1311 ImGui::Text(tr("%s route slots: used %d/%d, default %d, missing starts %d"),
1312 audit_includes_all_rooms_ ? "Project" : "Loaded-room",
1313 used_count, kTrackSlotCount, default_count,
1314 missing_start_count);
1315 ImGui::TextDisabled(
1316 tr("Route usage comes from minecart sprite subtypes, not visual track "
1317 "piece subtypes."));
1318 ImGui::EndTabItem();
1319 }
1320
1321 if (ImGui::BeginTabItem(tr("Collision"))) {
1322 ImGui::TextDisabled(
1323 tr("Audit room collision, preview generated changes, then apply them "
1324 "as one undoable dungeon edit."));
1325
1326 int rooms_needing_collision = 0;
1327 int protected_rooms = 0;
1328 for (const auto& [room_id, audit] : room_audit_) {
1329 if (!audit.track_subtypes.empty() && !audit.has_any_custom_collision) {
1330 ++rooms_needing_collision;
1331 } else if (!audit.track_subtypes.empty() &&
1332 audit.has_any_custom_collision) {
1333 ++protected_rooms;
1334 }
1335 }
1336 ImGui::TextDisabled(tr("%s audit: %d room(s) need collision."),
1337 audit_includes_all_rooms_ ? "Project" : "Loaded-room",
1338 rooms_needing_collision);
1339
1340 if (collision_preview_.empty()) {
1341 if (ImGui::Button(ICON_MD_PREVIEW " Preview All Rooms")) {
1342 const absl::Status status = BuildAllEligibleCollisionPreview();
1343 status_message_ = status.ok()
1344 ? absl::StrFormat("Preview ready for %d rooms.",
1345 collision_preview_.size())
1346 : std::string(status.message());
1347 show_success_ = status.ok();
1348 }
1349 ImGui::TextDisabled(
1350 tr("Scans all %d rooms. The list below otherwise reflects loaded "
1351 "rooms only."),
1352 static_cast<int>(rooms_->size()));
1353 } else {
1354 int preview_tiles = 0;
1355 for (const auto& result : collision_preview_) {
1356 preview_tiles += result.tiles_generated;
1357 }
1358 ImGui::Text(ICON_MD_PREVIEW " Preview: %d rooms, %d collision tiles",
1359 collision_preview_.size(), preview_tiles);
1361 ImGui::BeginDisabled();
1362 }
1363 if (ImGui::Button(absl::StrFormat(ICON_MD_CHECK_CIRCLE
1364 " Apply Preview to %d Rooms",
1365 collision_preview_.size())
1366 .c_str())) {
1367 ImGui::OpenPopup("Confirm Minecart Collision Apply");
1368 }
1370 ImGui::EndDisabled();
1371 }
1372 ImGui::SameLine();
1373 if (ImGui::Button(ICON_MD_CANCEL " Discard Preview")) {
1375 status_message_ = "Collision preview discarded.";
1376 show_success_ = true;
1377 }
1378
1379 if (ImGui::BeginPopupModal("Confirm Minecart Collision Apply", nullptr,
1380 ImGuiWindowFlags_AlwaysAutoResize)) {
1381 ImGui::Text(tr("Apply generated collision to %d rooms?"),
1382 collision_preview_.size());
1383 ImGui::TextDisabled(tr("%d collision tiles will be added."),
1384 preview_tiles);
1385 ImGui::TextDisabled(
1386 tr("This changes dungeon room models as one undoable edit. ROM "
1387 "bytes change only when you save."));
1388 if (ImGui::Button(ICON_MD_CHECK_CIRCLE " Apply Changes")) {
1389 const int applied_rooms = static_cast<int>(collision_preview_.size());
1390 const absl::Status status = ApplyCollisionPreview();
1392 status.ok()
1393 ? absl::StrFormat(
1394 "Applied collision to %d rooms. Save the ROM to "
1395 "publish it.",
1396 applied_rooms)
1397 : std::string(status.message());
1398 show_success_ = status.ok();
1399 ImGui::CloseCurrentPopup();
1400 }
1401 ImGui::SameLine();
1402 if (ImGui::Button(ICON_MD_CANCEL " Cancel")) {
1403 ImGui::CloseCurrentPopup();
1404 }
1405 ImGui::EndPopup();
1406 }
1407 }
1408
1409 if (protected_rooms > 0) {
1410 ImGui::TextDisabled(
1411 tr("%d room(s) with existing custom collision are protected and "
1412 "excluded."),
1413 protected_rooms);
1414 }
1415
1416 ImGui::BeginChild("##TrackAuditRooms", ImVec2(0, 160), true);
1417 std::vector<int> audited_room_ids;
1418 audited_room_ids.reserve(room_audit_.size());
1419 for (const auto& [room_id, audit] : room_audit_) {
1420 audited_room_ids.push_back(room_id);
1421 }
1422 std::sort(audited_room_ids.begin(), audited_room_ids.end());
1423 for (int room_id : audited_room_ids) {
1424 const auto& audit = room_audit_.at(room_id);
1425
1426 // Status icon
1427 if (!audit.has_track_collision && audit.has_any_custom_collision) {
1428 ImGui::TextColored(
1429 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1431 " Room 0x%03X (existing custom collision; protected)",
1432 room_id);
1433 } else if (!audit.has_track_collision) {
1434 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.1f, 1.0f),
1435 ICON_MD_ERROR " Room 0x%03X (no collision)",
1436 room_id);
1437 } else if (!audit.has_minecart_on_stop) {
1438 ImGui::TextColored(
1439 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1440 ICON_MD_WARNING_AMBER " Room 0x%03X (no cart on stop)", room_id);
1441 } else {
1442 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1443 ICON_MD_CHECK_CIRCLE " Room 0x%03X", room_id);
1444 }
1445
1446 ImGui::SameLine();
1447 ImGui::PushID(room_id);
1448 if (ImGui::SmallButton(ICON_MD_ARROW_FORWARD)) {
1451 }
1452 }
1453 if (ImGui::IsItemHovered()) {
1454 ImGui::SetTooltip(tr("Navigate to room 0x%03X"), room_id);
1455 }
1456
1457 // Preview only; applying is a separate, explicit batch action above.
1458 if (rooms_ && !audit.track_subtypes.empty() &&
1459 !audit.has_any_custom_collision) {
1460 ImGui::SameLine();
1461 if (ImGui::SmallButton(
1462 absl::StrFormat(ICON_MD_PREVIEW " Preview##%d", room_id)
1463 .c_str())) {
1464 const absl::Status status = BuildCollisionPreview({room_id});
1466 status.ok()
1467 ? absl::StrFormat("Preview ready for room 0x%03X.", room_id)
1468 : std::string(status.message());
1469 show_success_ = status.ok();
1470 }
1471 if (ImGui::IsItemHovered()) {
1472 ImGui::SetTooltip(
1473 tr("Preview generated collision without changing the room"));
1474 }
1475 }
1476
1477 ImGui::PopID();
1478 }
1479 ImGui::EndChild();
1481 ImGui::EndTabItem();
1482 }
1483
1484 ImGui::EndTabBar();
1485
1486 if (!status_message_.empty() && !picking_mode_) {
1487 ImGui::Separator();
1488 ImGui::TextColored(show_success_ ? ImVec4(0, 1, 0, 1) : ImVec4(1, 0, 0, 1),
1489 "%s", status_message_.c_str());
1490 }
1491}
1492
1494 load_attempted_ = true;
1495
1496 if (project_ == nullptr || !project_->hack_manifest.loaded() ||
1498 return absl::FailedPreconditionError(
1499 "Hack manifest does not define minecart_tracks.source");
1500 }
1501 const core::MinecartTrackLayout::Source source_identity =
1503
1504 std::filesystem::path source_path;
1506 std::string source_bytes;
1507 ASSIGN_OR_RETURN(source_bytes, ReadSourceFile(source_path));
1508 auto document_or =
1509 MinecartTrackSourceDocument::Parse(std::move(source_bytes));
1510 if (!document_or.ok()) {
1511 return document_or.status();
1512 }
1513 if (!project_->hack_manifest.minecart_track_layout().source.has_value() ||
1515 source_identity) {
1516 return absl::AbortedError(
1517 "Hack manifest minecart_tracks.source changed while loading tracks");
1518 }
1519
1520 std::vector<MinecartTrack> candidate_tracks = document_or->tracks();
1521 const std::string source_sha256 =
1522 core::ComputeSourceArtifactSha256(document_or->source_bytes());
1523
1524 tracks_ = candidate_tracks;
1525 loaded_tracks_ = std::move(candidate_tracks);
1526 source_document_ = std::move(*document_or);
1527 loaded_source_identity_ = source_identity;
1528 loaded_source_path_ = std::move(source_path);
1529 loaded_source_sha256_ = source_sha256;
1530 loaded_ = true;
1531 audit_dirty_ = true;
1532 status_message_.clear();
1533 show_success_ = true;
1534 return absl::OkStatus();
1535}
1536
1538 if (!loaded_ || !source_document_.has_value() ||
1539 !loaded_source_identity_.has_value() || loaded_source_path_.empty() ||
1540 loaded_source_sha256_.empty()) {
1541 return absl::FailedPreconditionError("Minecart tracks are not loaded");
1542 }
1543 if (!HasUnpublishedChanges()) {
1544 return absl::FailedPreconditionError(
1545 "No unpublished minecart track drafts to publish");
1546 }
1547#if defined(__EMSCRIPTEN__)
1548 return absl::FailedPreconditionError(
1549 "Minecart source publishing is unavailable in browser builds because "
1550 "durable atomic filesystem publication cannot be guaranteed; drafts "
1551 "were kept");
1552#else
1555
1556 std::filesystem::path source_path;
1558 if (source_path != loaded_source_path_) {
1559 return absl::FailedPreconditionError(
1560 "Minecart source path changed after the tracks were loaded; drafts "
1561 "were kept");
1562 }
1563
1564 std::unique_ptr<core::SourceArtifactPublicationLock> publication_lock;
1565 ASSIGN_OR_RETURN(publication_lock,
1567 {source_path}, kMinecartSourcePublisherLabels));
1568
1569 // Recheck every source identity after acquiring the durable publication
1570 // lock. A manifest reload or path replacement must fail before mutation.
1572 std::filesystem::path locked_source_path;
1573 ASSIGN_OR_RETURN(locked_source_path, ResolveTrackSourcePath());
1574 if (locked_source_path != loaded_source_path_) {
1575 return absl::FailedPreconditionError(
1576 "Minecart source path changed while acquiring its publication lock; "
1577 "drafts were kept");
1578 }
1579
1580 std::string source_before;
1581 ASSIGN_OR_RETURN(source_before, ReadSourceFile(locked_source_path));
1582 const std::string source_sha256_before =
1583 core::ComputeSourceArtifactSha256(source_before);
1584 if (source_sha256_before != loaded_source_sha256_ ||
1585 source_before != source_document_->source_bytes()) {
1586 return absl::AbortedError(absl::StrFormat(
1587 "Minecart source SHA-256 CAS failed: expected %s, got %s; drafts were "
1588 "kept",
1589 loaded_source_sha256_, source_sha256_before));
1590 }
1591
1592 std::string source_after;
1593 ASSIGN_OR_RETURN(source_after, source_document_->Render(tracks_));
1594 auto published_document_or = MinecartTrackSourceDocument::Parse(source_after);
1595 if (!published_document_or.ok()) {
1596 return absl::DataLossError(
1597 absl::StrFormat("Rendered minecart source failed strict validation: %s",
1598 published_document_or.status().message()));
1599 }
1600 if (published_document_or->tracks() != tracks_) {
1601 return absl::DataLossError(
1602 "Rendered minecart source did not reproduce the draft tracks");
1603 }
1604 const std::string source_sha256_after =
1606 const std::vector<MinecartTrack> draft_tracks = tracks_;
1607
1608 std::vector<core::SourceArtifactUpdate> updates;
1609 updates.push_back(core::SourceArtifactUpdate{
1610 .target = locked_source_path,
1611 .before = source_before,
1612 .after = source_after,
1613 });
1615 *publication_lock, std::move(updates), loaded_source_sha256_,
1616 [&]() -> absl::Status {
1617 std::string reopened_source;
1618 ASSIGN_OR_RETURN(reopened_source, ReadSourceFile(locked_source_path));
1619 if (reopened_source != source_after ||
1620 core::ComputeSourceArtifactSha256(reopened_source) !=
1621 source_sha256_after) {
1622 return absl::DataLossError(
1623 "Published minecart source failed exact SHA-256 readback");
1624 }
1625 auto reopened_document_or =
1626 MinecartTrackSourceDocument::Parse(std::move(reopened_source));
1627 if (!reopened_document_or.ok()) {
1628 return absl::DataLossError(absl::StrFormat(
1629 "Published minecart source failed strict readback validation: "
1630 "%s",
1631 reopened_document_or.status().message()));
1632 }
1633 if (reopened_document_or->tracks() != draft_tracks) {
1634 return absl::DataLossError(
1635 "Published minecart source track readback did not match the "
1636 "draft");
1637 }
1638 return absl::OkStatus();
1639 }));
1640
1641 tracks_ = published_document_or->tracks();
1643 source_document_ = std::move(*published_document_or);
1644 loaded_source_path_ = std::move(locked_source_path);
1645 loaded_source_sha256_ = source_sha256_after;
1647 audit_dirty_ = true;
1648 return absl::OkStatus();
1649#endif
1650}
1651
1652} // namespace yaze::editor
bool is_loaded() const
Definition rom.h:155
const MinecartTrackLayout & minecart_track_layout() const
bool loaded() const
Check if the manifest has been loaded.
zelda3::Room * GetIfMaterialized(int room_id)
zelda3::GameData * game_data() const
bool IsDefaultTrack(const MinecartTrack &track) const
absl::StatusOr< zelda3::GeneratorOptions > ResolveGeneratorOptions() const
std::optional< core::MinecartTrackLayout::Source > bound_source_identity_
bool UpdateOverlayList(const char *label, std::string &input, OverlayListMember member)
void Draw(bool *p_open) override
Draw the panel content.
const std::vector< MinecartTrack > & GetTracks()
ProjectDraftChangedCallback project_draft_changed_callback_
std::vector< uint16_t > project::DungeonOverlaySettings::* OverlayListMember
std::unordered_map< int, RoomTrackAudit > room_audit_
CollisionBatchApplyCallback collision_batch_apply_callback_
absl::Status NotifyProjectChanged(const project::DungeonOverlaySettings &overlay)
std::vector< zelda3::TrackCollisionResult > collision_preview_
std::optional< MinecartTrackSourceDocument > source_document_
absl::StatusOr< bool > CommitOverlayList(std::string &input, OverlayListMember member)
absl::Status UpdateTrack(size_t track_index, const MinecartTrack &track)
absl::Status RebindProjectContext(project::YazeProject *project)
absl::StatusOr< std::filesystem::path > ResolveTrackSourcePath() const
absl::Status BuildCollisionPreview(const std::vector< int > &room_ids)
absl::Status SetProject(project::YazeProject *project)
std::optional< core::MinecartTrackLayout::Source > loaded_source_identity_
std::unordered_map< int, std::vector< int > > route_usage_rooms_
void RebuildAuditCache(bool include_unmaterialized=false)
void SetPickedCoordinates(int room_id, uint16_t camera_x, uint16_t camera_y)
std::optional< project::DungeonOverlaySettings > overlay_inputs_model_
static absl::StatusOr< MinecartTrackSourceDocument > Parse(std::string source_bytes)
#define ICON_MD_MY_LOCATION
Definition icons.h:1270
#define ICON_MD_INFO
Definition icons.h:993
#define ICON_MD_CANCEL
Definition icons.h:364
#define ICON_MD_ARROW_FORWARD
Definition icons.h:184
#define ICON_MD_WARNING_AMBER
Definition icons.h:2124
#define ICON_MD_TUNE
Definition icons.h:2022
#define ICON_MD_REFRESH
Definition icons.h:1572
#define ICON_MD_ERROR
Definition icons.h:686
#define ICON_MD_RESTORE
Definition icons.h:1605
#define ICON_MD_CHECK_CIRCLE
Definition icons.h:400
#define ICON_MD_PREVIEW
Definition icons.h:1512
#define ICON_MD_SAVE
Definition icons.h:1644
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
absl::Status PublishSourceArtifacts(const SourceArtifactPublicationLock &lock, std::vector< SourceArtifactUpdate > updates, std::string_view expected_primary_sha256, SourceArtifactReadbackValidator readback_validator)
std::string ComputeSourceArtifactSha256(std::string_view content)
absl::StatusOr< std::unique_ptr< SourceArtifactPublicationLock > > AcquireSourceArtifactPublicationLock(const std::vector< fs::path > &targets, const SourceArtifactPublisherLabels &labels)
absl::StatusOr< std::string > ReadSourceFile(const std::filesystem::path &path)
bool OverlaySettingsEqual(const project::DungeonOverlaySettings &lhs, const project::DungeonOverlaySettings &rhs)
std::optional< core::MinecartTrackLayout::Source > ProjectSourceIdentity(const project::YazeProject *project)
absl::StatusOr< std::vector< uint16_t > > ParseHexList(const std::string &input)
bool IsStrictDescendant(const std::filesystem::path &path, const std::filesystem::path &root)
Editors are the view controllers for the application.
constexpr size_t kMinecartTrackSlotCount
bool InputHexWordCustom(const char *label, uint16_t *data, float input_width)
Definition input.cc:863
Room LoadRoomFromRom(Rom *rom, int room_id)
Definition room.cc:648
bool IsMinecartTrackGraphicsSubtype(int object_id, int subtype)
absl::StatusOr< TrackCollisionResult > GenerateTrackCollision(Room *room, const GeneratorOptions &options)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::optional< Source > source
Dungeon overlay configuration (per-project).
Definition project.h:93
std::vector< uint16_t > track_object_ids
Definition project.h:100
std::vector< uint16_t > minecart_sprite_ids
Definition project.h:101
std::vector< uint16_t > track_stop_tiles
Definition project.h:96
std::vector< uint16_t > track_tiles
Definition project.h:95
std::vector< uint16_t > track_switch_tiles
Definition project.h:97
Modern project structure with comprehensive settings consolidation.
Definition project.h:172
bool project_opened() const
Definition project.h:348
core::HackManifest hack_manifest
Definition project.h:212
DungeonOverlaySettings dungeon_overlay
Definition project.h:202
std::array< uint8_t, 64 *64 > tiles