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 collision_stamping_ = false;
161 paired_track_draft_ = false;
162 canvas_editing_ = false;
163 selected_track_ = -1;
164 tracks_.clear();
165 loaded_tracks_.clear();
166 source_document_.reset();
168 loaded_source_path_.clear();
169 loaded_source_sha256_.clear();
170 load_attempted_ = false;
171 loaded_ = false;
174 audit_dirty_ = true;
175}
176
178 const std::string current_filepath = project_ ? project_->filepath : "";
179 const auto current_source_identity = ProjectSourceIdentity(project_);
180 if (current_filepath == bound_project_filepath_ &&
181 current_source_identity == bound_source_identity_) {
182 return absl::OkStatus();
183 }
184 if (HasUnpublishedChanges()) {
185 return absl::FailedPreconditionError(
186 "Project descriptor moved or minecart source changed; discard "
187 "minecart track drafts before rebinding the source");
188 }
189
190 bound_project_filepath_ = current_filepath;
191 bound_source_identity_ = current_source_identity;
193 overlay_inputs_model_.reset();
195 return absl::OkStatus();
196}
197
199 project::YazeProject* project) {
200 const std::string next_filepath = project ? project->filepath : "";
201 const auto next_source_identity = ProjectSourceIdentity(project);
202 if (project_ == project && bound_project_filepath_ == next_filepath &&
203 bound_source_identity_ == next_source_identity) {
204 return absl::OkStatus();
205 }
206 if (HasUnpublishedChanges()) {
207 return absl::FailedPreconditionError(
208 "Discard minecart track drafts before changing projects");
209 }
210
211 project_ = project;
212 bound_project_filepath_ = next_filepath;
213 bound_source_identity_ = next_source_identity;
215 overlay_inputs_model_.reset();
217 return absl::OkStatus();
218}
219
221 project::YazeProject* project) {
222 const absl::Status status = SetProject(project);
223 if (!status.ok()) {
224 status_message_ = std::string(status.message());
225 show_success_ = false;
226 return status;
227 }
228
229 // Project descriptors are reapplied into stable session storage. Refresh a
230 // same-pointer binding only when the model changed; otherwise preserve text
231 // currently being edited until it can be validated and committed.
232 if (project != nullptr && (!overlay_inputs_model_.has_value() ||
233 !OverlaySettingsEqual(*overlay_inputs_model_,
234 project->dungeon_overlay))) {
238 }
239 return absl::OkStatus();
240}
241
243 if (project_ == nullptr || !project_->hack_manifest.loaded()) {
244 return absl::FailedPreconditionError(
245 "A loaded hack manifest is required to publish minecart tracks");
246 }
247 const auto& current_source =
249 if (!current_source.has_value()) {
250 return absl::FailedPreconditionError(
251 "Hack manifest does not define minecart_tracks.source");
252 }
253 if (!loaded_source_identity_.has_value() ||
254 *current_source != *loaded_source_identity_) {
255 return absl::FailedPreconditionError(
256 "Hack manifest minecart_tracks.source changed after the tracks were "
257 "loaded; drafts were kept");
258 }
259 return absl::OkStatus();
260}
261
262absl::StatusOr<std::filesystem::path>
264 if (project_ != nullptr && project_->filepath != bound_project_filepath_) {
265 return absl::FailedPreconditionError(
266 "Project descriptor moved; reload minecart tracks to rebind the "
267 "source");
268 }
269 if (project_ == nullptr || bound_project_filepath_.empty()) {
270 return absl::FailedPreconditionError(
271 "An open project descriptor is required for minecart tracks");
272 }
273 if (!project_->hack_manifest.loaded()) {
274 return absl::FailedPreconditionError(
275 "A loaded hack manifest is required for minecart tracks");
276 }
277 const auto& source = project_->hack_manifest.minecart_track_layout().source;
278 if (!source.has_value()) {
279 return absl::FailedPreconditionError(
280 "Hack manifest does not define minecart_tracks.source");
281 }
282
283 std::error_code ec;
284 std::filesystem::path descriptor_path(bound_project_filepath_);
285 if (descriptor_path.is_relative()) {
286 descriptor_path = std::filesystem::absolute(descriptor_path, ec);
287 if (ec) {
288 return absl::InvalidArgumentError(absl::StrFormat(
289 "Could not resolve project descriptor path: %s", ec.message()));
290 }
291 }
292
293 const std::filesystem::path project_root =
294 std::filesystem::weakly_canonical(descriptor_path.parent_path(), ec);
295 if (ec || project_root.empty() ||
296 !std::filesystem::is_directory(project_root, ec) || ec) {
297 return absl::InvalidArgumentError(
298 "Project descriptor parent is not an existing directory");
299 }
300
301 const std::filesystem::path candidate =
302 (project_root / source->path).lexically_normal();
303 const std::filesystem::file_status candidate_status =
304 std::filesystem::symlink_status(candidate, ec);
305 if (ec) {
306 return absl::NotFoundError(
307 absl::StrFormat("Minecart source not found: %s", candidate.string()));
308 }
309 if (std::filesystem::is_symlink(candidate_status)) {
310 return absl::PermissionDeniedError(
311 "Minecart source may not be a symbolic link");
312 }
313 const std::filesystem::path resolved =
314 std::filesystem::canonical(candidate, ec);
315 if (ec) {
316 return absl::NotFoundError(
317 absl::StrFormat("Minecart source not found: %s", candidate.string()));
318 }
319 if (!IsStrictDescendant(resolved, project_root)) {
320 return absl::PermissionDeniedError(
321 "Minecart source resolves outside the project root");
322 }
323 if (!std::filesystem::is_regular_file(resolved, ec) || ec) {
324 return absl::InvalidArgumentError(
325 "Minecart source must be an existing regular file");
326 }
327 return resolved;
328}
329
333
349
351 const absl::StatusOr<bool> committed = CommitOverlayInputsForSave();
352 return committed.ok() ? absl::OkStatus() : committed.status();
353}
354
355absl::Status MinecartTrackEditorPanel::UpdateTrack(size_t track_index,
356 const MinecartTrack& track) {
357 if (!loaded_) {
358 return absl::FailedPreconditionError("Minecart tracks are not loaded");
359 }
360 if (track_index >= tracks_.size()) {
361 return absl::InvalidArgumentError("Minecart track index is out of range");
362 }
363 MinecartTrack updated = track;
364 updated.id = static_cast<int>(track_index);
365 const auto before = tracks_[track_index];
366 if (before == updated)
367 return absl::OkStatus();
369 return track_edit_callback_(before, updated, false);
370 return RestoreTrackDraft(track_generation_, before, updated);
371}
372
374 uint64_t generation, const MinecartTrack& expected,
375 const MinecartTrack& replacement) {
376 if (generation != track_generation_ || !loaded_ || expected.id < 0 ||
377 expected.id >= static_cast<int>(tracks_.size()) ||
378 tracks_[expected.id] != expected || replacement.id != expected.id) {
379 return absl::FailedPreconditionError(
380 "Track source or draft changed; this edit cannot be replayed");
381 }
382 auto candidate = tracks_;
383 candidate[expected.id] = replacement;
384 const auto validated = source_document_->Render(candidate);
385 if (!validated.ok())
386 return validated.status();
387 tracks_[expected.id] = replacement;
388 selected_track_ = expected.id;
389 audit_dirty_ = true;
390 return absl::OkStatus();
391}
392
394 const MinecartTrack& before, const MinecartTrack& after) {
396 return absl::FailedPreconditionError(
397 "Open the dungeon editor to move a cart and its start together");
398 return track_edit_callback_(before, after, true);
399}
400
403 return absl::FailedPreconditionError(
404 "Use dungeon Undo to reverse paired cart moves before discarding "
405 "source drafts");
406 }
407 if (!loaded_) {
408 return absl::FailedPreconditionError("Minecart tracks are not loaded");
409 }
414 audit_dirty_ = true;
415 return absl::OkStatus();
416}
417
419 const absl::Status binding_status = RefreshProjectBinding();
420 if (!binding_status.ok()) {
421 return binding_status;
422 }
423 if (HasUnpublishedChanges()) {
424 return absl::FailedPreconditionError(
425 "Discard minecart track drafts before reloading the source");
426 }
427 return LoadTracks();
428}
429
447
455
457 std::string& input,
458 OverlayListMember member) {
459 if (ImGui::InputText(label, &input)) {
460 const absl::Status draft_status = NotifyProjectDraftChanged();
461 if (!draft_status.ok()) {
462 input = FormatHexList(project_->dungeon_overlay.*member);
464 absl::StrFormat("Overlay draft rejected: %s", draft_status.message());
465 show_success_ = false;
466 return false;
467 }
468 }
469 if (ImGui::IsItemDeactivatedAfterEdit()) {
470 const absl::StatusOr<bool> changed = CommitOverlayList(input, member);
471 return changed.ok() && *changed;
472 }
473 return false;
474}
475
477 std::string& input, OverlayListMember member) {
478 if (project_ == nullptr) {
479 const absl::Status status =
480 absl::FailedPreconditionError("No project is bound to the panel");
481 status_message_ = std::string(status.message());
482 show_success_ = false;
483 return status;
484 }
485
486 const auto parsed_or = ParseHexList(input);
487 if (!parsed_or.ok()) {
488 status_message_ = absl::StrFormat("Overlay update rejected: %s",
489 parsed_or.status().message());
490 show_success_ = false;
491 return parsed_or.status();
492 }
493
495 std::vector<uint16_t>& candidate_target = candidate.*member;
496 if (*parsed_or == candidate_target) {
497 input = FormatHexList(*parsed_or);
499 status_message_.clear();
500 show_success_ = false;
501 return false;
502 }
503
504 candidate_target = *parsed_or;
505 const absl::Status notify_status = NotifyProjectChanged(candidate);
506 if (!notify_status.ok()) {
507 input = FormatHexList(project_->dungeon_overlay.*member);
509 absl::StrFormat("Overlay update rejected: %s", notify_status.message());
510 show_success_ = false;
511 return notify_status;
512 }
513
514 project_->dungeon_overlay = std::move(candidate);
515 input = FormatHexList(project_->dungeon_overlay.*member);
518 audit_dirty_ = true;
519 status_message_ = "Overlay settings updated; save the project to persist.";
520 show_success_ = true;
521 return true;
522}
523
525 if (project_ == nullptr) {
526 return absl::FailedPreconditionError("No project is bound to the panel");
527 }
528
531 auto parse_field = [](const char* field_name, const std::string& input,
532 std::vector<uint16_t>* target) -> absl::Status {
533 const auto parsed_or = ParseHexList(input);
534 if (!parsed_or.ok()) {
535 return absl::Status(
536 parsed_or.status().code(),
537 absl::StrFormat("%s: %s", field_name, parsed_or.status().message()));
538 }
539 *target = *parsed_or;
540 return absl::OkStatus();
541 };
542
543 absl::Status parse_status = parse_field(
544 "Track Tiles", overlay_track_tiles_input_, &candidate.track_tiles);
545 if (parse_status.ok()) {
546 parse_status = parse_field("Stop Tiles", overlay_track_stop_tiles_input_,
547 &candidate.track_stop_tiles);
548 }
549 if (parse_status.ok()) {
550 parse_status =
551 parse_field("Switch Tiles", overlay_track_switch_tiles_input_,
552 &candidate.track_switch_tiles);
553 }
554 if (parse_status.ok()) {
555 parse_status =
556 parse_field("Track Object IDs", overlay_track_object_ids_input_,
557 &candidate.track_object_ids);
558 }
559 if (parse_status.ok()) {
560 parse_status =
561 parse_field("Minecart Sprite IDs", overlay_minecart_sprite_ids_input_,
562 &candidate.minecart_sprite_ids);
563 }
564 if (!parse_status.ok()) {
566 absl::StrFormat("Project save blocked: %s", parse_status.message());
567 show_success_ = false;
568 return parse_status;
569 }
570
571 auto normalize_inputs = [this]() {
573 FormatHexList(project_->dungeon_overlay.track_tiles);
583 };
584
585 if (OverlaySettingsEqual(candidate, project_->dungeon_overlay)) {
586 normalize_inputs();
587 return false;
588 }
589
590 const absl::Status notify_status = NotifyProjectChanged(candidate);
591 if (!notify_status.ok()) {
593 absl::StrFormat("Project save blocked: %s", notify_status.message());
594 show_success_ = false;
595 return notify_status;
596 }
597
598 project_->dungeon_overlay = std::move(candidate);
599 normalize_inputs();
601 audit_dirty_ = true;
602 return true;
603}
604
606 if (project_ == nullptr) {
607 const absl::Status status =
608 absl::FailedPreconditionError("No project is bound to the panel");
609 status_message_ = std::string(status.message());
610 show_success_ = false;
611 return status;
612 }
613
615 const bool changed =
616 !overlay.track_tiles.empty() || !overlay.track_stop_tiles.empty() ||
617 !overlay.track_switch_tiles.empty() ||
618 !overlay.track_object_ids.empty() || !overlay.minecart_sprite_ids.empty();
619 if (!changed) {
622 status_message_.clear();
623 show_success_ = false;
624 return false;
625 }
626
627 overlay.track_tiles.clear();
628 overlay.track_stop_tiles.clear();
629 overlay.track_switch_tiles.clear();
630 overlay.track_object_ids.clear();
631 overlay.minecart_sprite_ids.clear();
632 const absl::Status notify_status = NotifyProjectChanged(overlay);
633 if (!notify_status.ok()) {
635 absl::StrFormat("Overlay reset rejected: %s", notify_status.message());
636 show_success_ = false;
637 return notify_status;
638 }
639
640 project_->dungeon_overlay = std::move(overlay);
644 audit_dirty_ = true;
645 status_message_ = "Overlay settings reset; save the project to persist.";
646 show_success_ = true;
647 return true;
648}
649
651 const project::DungeonOverlaySettings& overlay) {
652 if (project_ == nullptr) {
653 return absl::FailedPreconditionError("No project is bound to the panel");
654 }
656 return absl::FailedPreconditionError(
657 "Project change tracking is unavailable for minecart overlays");
658 }
659 return project_changed_callback_(overlay);
660}
661
663 if (project_ == nullptr) {
664 return absl::FailedPreconditionError("No project is bound to the panel");
665 }
667 return absl::FailedPreconditionError(
668 "Project draft tracking is unavailable for minecart overlays");
669 }
671}
672
674 if (project_ == nullptr || !project_->project_opened()) {
675 return absl::FailedPreconditionError("No open project to save");
676 }
678 return absl::FailedPreconditionError(
679 "Project save is unavailable outside the editor manager");
680 }
681 const absl::StatusOr<bool> committed = CommitOverlayInputsForSave();
682 if (!committed.ok()) {
683 return committed.status();
684 }
685 return project_save_callback_();
686}
687
689 if (!project_) {
690 return;
691 }
692
694
695 if (!ImGui::CollapsingHeader(ICON_MD_TUNE " Advanced")) {
696 return;
697 }
698
699 ImGui::TextDisabled(tr("Advanced collision detection IDs and tile values."));
700 ImGui::TextDisabled(tr("Empty list = defaults. Use hex (0xB0) or decimal."));
701 ImGui::TextDisabled(
702 tr("Defaults: Track 0xB0-0xBE | Stop 0xB7-0xBA | Switch 0xD0-0xD3 | "
703 "Track Obj 0x31 | Cart Sprite 0xA3"));
704
705 bool changed = false;
706 changed |= UpdateOverlayList("Track Tiles", overlay_track_tiles_input_,
708 changed |=
711 changed |=
714 changed |=
717 changed |= UpdateOverlayList(
718 "Minecart Sprite IDs", overlay_minecart_sprite_ids_input_,
720
721 if (ImGui::Button(tr("Reset Overlay Defaults"))) {
722 const absl::StatusOr<bool> reset = ResetOverlaySettings();
723 changed |= reset.ok() && *reset;
724 }
725
726 if (changed) {
727 ImGui::TextDisabled(tr("Remember to save the project to persist changes."));
728 }
729}
730
731const std::vector<MinecartTrack>& MinecartTrackEditorPanel::GetTracks() {
732 const absl::Status binding_status = RefreshProjectBinding();
733 if (!binding_status.ok()) {
734 status_message_ = std::string(binding_status.message());
735 show_success_ = false;
736 return tracks_;
737 }
738 if (!load_attempted_) {
739 const absl::Status status = LoadTracks();
740 if (!status.ok()) {
741 status_message_ = std::string(status.message());
742 show_success_ = false;
743 }
744 }
745 return tracks_;
746}
747
749 canvas_editing_ = true;
750 selected_track_ = track_index;
751 picking_mode_ = true;
752 picking_track_index_ = track_index;
753 status_message_ = absl::StrFormat(
754 "Click on the dungeon canvas to set Track %d position", track_index);
755 show_success_ = false;
756}
757
763
765 const MinecartTrack& track) const {
766 return track.room_id == kDefaultTrackRoom &&
767 track.start_x == kDefaultTrackX && track.start_y == kDefaultTrackY;
768}
769
770void MinecartTrackEditorPanel::RebuildAuditCache(bool include_unmaterialized) {
771 room_audit_.clear();
772 route_usage_rooms_.clear();
773 route_slot_used_.assign(kTrackSlotCount, false);
774
775 if (!rooms_) {
776 audit_dirty_ = false;
777 return;
778 }
779
780 std::array<bool, 256> track_tiles{};
781 std::array<bool, 256> stop_tiles{};
782 std::array<bool, 256> switch_tiles{};
783 auto apply_list = [](std::array<bool, 256>& dest,
784 const std::vector<uint16_t>& values) {
785 dest.fill(false);
786 for (uint16_t value : values) {
787 if (value < dest.size()) {
788 dest[value] = true;
789 }
790 }
791 };
792
793 if (project_ && !project_->dungeon_overlay.track_tiles.empty()) {
794 apply_list(track_tiles, project_->dungeon_overlay.track_tiles);
795 } else {
796 std::vector<uint16_t> default_track_tiles;
797 for (uint16_t tile = 0xB0; tile <= 0xBE; ++tile) {
798 default_track_tiles.push_back(tile);
799 }
800 apply_list(track_tiles, default_track_tiles);
801 }
802
804 apply_list(stop_tiles, project_->dungeon_overlay.track_stop_tiles);
805 } else {
806 apply_list(stop_tiles, {0xB7, 0xB8, 0xB9, 0xBA});
807 }
808
810 apply_list(switch_tiles, project_->dungeon_overlay.track_switch_tiles);
811 } else {
812 apply_list(switch_tiles, {0xD0, 0xD1, 0xD2, 0xD3});
813 }
814
815 std::vector<uint16_t> track_object_ids = {0x31};
816 std::vector<uint16_t> minecart_sprite_ids = {0xA3};
817 if (project_) {
819 track_object_ids = project_->dungeon_overlay.track_object_ids;
820 }
822 minecart_sprite_ids = project_->dungeon_overlay.minecart_sprite_ids;
823 }
824 }
825
826 std::unordered_map<int, bool> track_object_id_map;
827 for (uint16_t id : track_object_ids) {
828 track_object_id_map[static_cast<int>(id)] = true;
829 }
830 std::unordered_map<int, bool> minecart_sprite_id_map;
831 for (uint16_t id : minecart_sprite_ids) {
832 minecart_sprite_id_map[static_cast<int>(id)] = true;
833 }
834
835 auto audit_room = [&](int room_id, zelda3::Room& room) {
836 RoomTrackAudit audit;
837
838 room.EnsureObjectsLoaded();
839 room.EnsureSpritesLoaded();
840
841 std::array<bool, kTrackSlotCount> seen_subtype{};
842
843 for (const auto& obj : room.GetTileObjects()) {
844 if (!track_object_id_map[static_cast<int>(obj.id_)]) {
845 continue;
846 }
847 int subtype = obj.size_ & 0x1F;
848 if (zelda3::IsMinecartTrackGraphicsSubtype(obj.id_, subtype) &&
849 subtype >= 0 && subtype < kTrackSlotCount) {
850 if (!seen_subtype[static_cast<size_t>(subtype)]) {
851 seen_subtype[static_cast<size_t>(subtype)] = true;
852 audit.track_subtypes.push_back(subtype);
853 }
854 }
855 }
856
857 std::unordered_map<int, bool> stop_positions;
858 const auto& collision = room.custom_collision();
859 audit.has_any_custom_collision = HasAnyCustomCollision(collision);
860 if (audit.has_any_custom_collision) {
861 const auto& map = collision.tiles;
862 for (int y = 0; y < 64; ++y) {
863 for (int x = 0; x < 64; ++x) {
864 uint8_t tile = map[static_cast<size_t>(y * 64 + x)];
865 if (track_tiles[tile] || stop_tiles[tile] || switch_tiles[tile]) {
866 audit.has_track_collision = true;
867 }
868 if (stop_tiles[tile]) {
869 audit.has_stop_tiles = true;
870 stop_positions[y * 64 + x] = true;
871 }
872 }
873 }
874 }
875
876 std::array<bool, kTrackSlotCount> seen_route_slot{};
877 for (const auto& sprite : room.GetSprites()) {
878 if (!minecart_sprite_id_map[static_cast<int>(sprite.id())]) {
879 continue;
880 }
881 audit.has_minecart_sprite = true;
882 const int route_slot = sprite.subtype();
883 if (route_slot >= 0 && route_slot < kTrackSlotCount &&
884 !seen_route_slot[static_cast<size_t>(route_slot)]) {
885 seen_route_slot[static_cast<size_t>(route_slot)] = true;
886 route_slot_used_[static_cast<size_t>(route_slot)] = true;
887 route_usage_rooms_[route_slot].push_back(room_id);
888 audit.route_slots.push_back(route_slot);
889 }
890 int tile_x = sprite.x() * 2;
891 int tile_y = sprite.y() * 2;
892 if (tile_x >= 0 && tile_x < 64 && tile_y >= 0 && tile_y < 64) {
893 int idx = tile_y * 64 + tile_x;
894 if (stop_positions[idx]) {
895 audit.has_minecart_on_stop = true;
896 }
897 }
898 }
899
900 if (audit.has_track_collision || !audit.track_subtypes.empty() ||
901 audit.has_minecart_sprite) {
902 room_audit_[room_id] = audit;
903 }
904 };
905
906 // Inspect every room. Materialized rooms are the authority for unsaved
907 // editor changes; unopened rooms are parsed into temporary models so a
908 // global audit does not depend on which room tabs happen to be open.
909 for (int room_id = 0; room_id < static_cast<int>(rooms_->size()); ++room_id) {
910 if (auto* room = rooms_->GetIfMaterialized(room_id)) {
911 audit_room(room_id, *room);
912 continue;
913 }
914 if (!include_unmaterialized) {
915 continue;
916 }
917 if (rooms_->rom() == nullptr || !rooms_->rom()->is_loaded()) {
918 continue;
919 }
920 zelda3::Room room(room_id, rooms_->rom(), rooms_->game_data());
921 audit_room(room_id, room);
922 }
923
924 audit_dirty_ = false;
925 audit_includes_all_rooms_ = include_unmaterialized;
926}
927
928absl::StatusOr<zelda3::GeneratorOptions>
931 if (project_ == nullptr ||
933 return options;
934 }
935 if (project_->dungeon_overlay.track_object_ids.size() != 1) {
936 return absl::FailedPreconditionError(
937 "Collision preview requires exactly one configured track object ID");
938 }
940 return options;
941}
942
944 const std::vector<int>& room_ids) {
945 if (rooms_ == nullptr) {
946 return absl::FailedPreconditionError("Dungeon rooms are unavailable");
947 }
948 if (room_ids.empty()) {
949 return absl::InvalidArgumentError("No eligible rooms to preview");
950 }
951
952 ASSIGN_OR_RETURN(const auto options, ResolveGeneratorOptions());
953 std::vector<int> sorted_room_ids = room_ids;
954 std::sort(sorted_room_ids.begin(), sorted_room_ids.end());
955 if (std::adjacent_find(sorted_room_ids.begin(), sorted_room_ids.end()) !=
956 sorted_room_ids.end()) {
957 return absl::InvalidArgumentError(
958 "Collision preview contains a duplicate room ID");
959 }
960
961 std::vector<zelda3::TrackCollisionResult> preview;
962 preview.reserve(sorted_room_ids.size());
963 for (int room_id : sorted_room_ids) {
964 if (room_id < 0 || room_id >= static_cast<int>(rooms_->size())) {
965 return absl::OutOfRangeError(absl::StrFormat(
966 "Collision preview room 0x%03X is out of range", room_id));
967 }
968 auto* room = rooms_->GetIfMaterialized(room_id);
969 if (room == nullptr) {
970 if (rooms_->rom() == nullptr || !rooms_->rom()->is_loaded()) {
971 return absl::FailedPreconditionError(absl::StrFormat(
972 "Collision preview room 0x%03X cannot be loaded without a ROM",
973 room_id));
974 }
975 auto& materialized = (*rooms_)[room_id];
976 materialized = zelda3::LoadRoomFromRom(rooms_->rom(), room_id);
977 materialized.SetGameData(rooms_->game_data());
978 room = &materialized;
979 }
980 room->EnsureObjectsLoaded();
981 if (HasAnyCustomCollision(room->custom_collision())) {
982 return absl::FailedPreconditionError(absl::StrFormat(
983 "Room 0x%03X already has custom collision; generation will not "
984 "replace it",
985 room_id));
986 }
987
988 ASSIGN_OR_RETURN(auto generated,
989 zelda3::GenerateTrackCollision(room, options));
990 generated.room_id = room_id;
991 if (!generated.collision_map.has_data || generated.tiles_generated <= 0) {
992 return absl::FailedPreconditionError(absl::StrFormat(
993 "Room 0x%03X has no supported minecart track pieces", room_id));
994 }
995 preview.push_back(std::move(generated));
996 }
997
999 collision_preview_ = std::move(preview);
1000 return absl::OkStatus();
1001}
1002
1004 RebuildAuditCache(/*include_unmaterialized=*/true);
1005 std::vector<int> room_ids;
1006 for (const auto& [room_id, audit] : room_audit_) {
1007 if (!audit.track_subtypes.empty() && !audit.has_any_custom_collision) {
1008 room_ids.push_back(room_id);
1009 }
1010 }
1011 return BuildCollisionPreview(room_ids);
1012}
1013
1015 if (collision_preview_.empty()) {
1016 return absl::FailedPreconditionError("No collision preview to apply");
1017 }
1019 return absl::FailedPreconditionError(
1020 "Minecart collision apply is unavailable outside the dungeon editor");
1021 }
1025 audit_dirty_ = true;
1026 return absl::OkStatus();
1027}
1028
1033
1035 if (!enabled) {
1036 canvas_editing_ = false;
1038 return;
1039 }
1040 // The canvas is usable before this inspector has ever been drawn.
1041 auto status = RefreshProjectBinding();
1042 if (status.ok() && !load_attempted_)
1043 status = LoadTracks();
1044 if (!status.ok() || tracks_.empty()) {
1045 canvas_editing_ = false;
1046 status_message_ = status.ok() ? "No cart starts are loaded"
1047 : std::string(status.message());
1048 show_success_ = false;
1049 return;
1050 }
1051 collision_stamping_ = false;
1052 canvas_editing_ = true;
1053}
1054
1056 if (project_ == nullptr) {
1057 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1058 tr("Open a project to edit minecart tracks."));
1059 return;
1060 }
1061
1062 const absl::Status binding_status = RefreshProjectBinding();
1063 if (!binding_status.ok()) {
1064 status_message_ = std::string(binding_status.message());
1065 show_success_ = false;
1066 }
1067 if (bound_project_filepath_.empty()) {
1068 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1069 tr("Open a project to edit minecart tracks."));
1070 return;
1071 }
1072
1073 if (!load_attempted_) {
1074 const absl::Status status = LoadTracks();
1075 if (!status.ok()) {
1076 status_message_ = std::string(status.message());
1077 show_success_ = false;
1078 }
1079 }
1080
1081 if (audit_dirty_) {
1083 }
1084
1085 DrawRailTools();
1086 ImGui::Separator();
1087 ImGui::TextUnformatted("Cart starts");
1089 if (ImGui::Checkbox("Edit starts on room canvas", &canvas_editing)) {
1091 }
1092 ImGui::TextWrapped(
1093 "Select a cart marker, then drag it onto a stop. "
1094 "Escape cancels. Arrow keys nudge one sprite cell.");
1095 if (selected_track_ >= 0 &&
1096 selected_track_ < static_cast<int>(tracks_.size())) {
1097 const auto& selected = tracks_[selected_track_];
1098 ImGui::Text("Cart %d - room %03X%s", selected.id, selected.room_id,
1099 HasUnpublishedChanges() ? " (unpublished changes)" : "");
1100 ImGui::TextDisabled("Moving a start also moves its matching placed cart.");
1101 }
1102 for (const auto& track : tracks_) {
1103 if (track.room_id != active_room_)
1104 continue;
1105 if (ImGui::Selectable(
1106 absl::StrFormat("Cart %d##cart_start_%d", track.id, track.id)
1107 .c_str(),
1108 selected_track_ == track.id)) {
1109 selected_track_ = track.id;
1110 set_canvas_editing(true);
1111 }
1112 }
1113 if (picking_mode_) {
1114 if (ImGui::Button(ICON_MD_CANCEL " Cancel Pick")) {
1116 }
1117 ImGui::SameLine();
1118 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f),
1119 ICON_MD_MY_LOCATION " Picking for Track %d...",
1121 }
1122 if (!ImGui::BeginTabBar("##MinecartTasks")) {
1123 return;
1124 }
1125
1126 if (ImGui::BeginTabItem(tr("Routes"))) {
1127 ImGui::TextDisabled(
1128 tr("Edit route start slots and publish the manifest-owned ASM "
1129 "source."));
1130#if defined(__EMSCRIPTEN__)
1131 ImGui::TextDisabled(
1132 tr("Source publishing is unavailable in browser builds; drafts are "
1133 "retained."));
1134#endif
1135 const bool has_unpublished_changes = HasUnpublishedChanges();
1136 const bool can_publish =
1137 has_unpublished_changes && kSourcePublishingAvailable;
1138 if (!can_publish) {
1139 ImGui::BeginDisabled();
1140 }
1141 if (ImGui::Button(ICON_MD_SAVE " Publish Tracks")) {
1142 const absl::Status status = SaveTracks();
1144 status.ok()
1145 ? "Minecart ASM source published only. Save pending dungeon/ROM "
1146 "edits to the development ROM, rebuild the patched ROM, then "
1147 "reopen/reload the patched ROM in Yaze before testing."
1148 : std::string(status.message());
1149 show_success_ = status.ok();
1150 }
1151 if (!can_publish) {
1152 ImGui::EndDisabled();
1153 }
1154 ImGui::SameLine();
1155 if (!has_unpublished_changes) {
1156 ImGui::BeginDisabled();
1157 }
1158 if (ImGui::Button(ICON_MD_RESTORE " Discard Drafts")) {
1159 const absl::Status status = DiscardUnpublishedChanges();
1160 status_message_ = status.ok() ? "Minecart track drafts discarded."
1161 : std::string(status.message());
1162 show_success_ = status.ok();
1163 }
1164 if (!has_unpublished_changes) {
1165 ImGui::EndDisabled();
1166 }
1167 ImGui::SameLine();
1168 if (ImGui::Button(ICON_MD_REFRESH " Reload Source")) {
1169 const absl::Status status = ReloadTracks();
1170 status_message_ = status.ok() ? "Minecart track source reloaded."
1171 : std::string(status.message());
1172 show_success_ = status.ok();
1173 }
1174 ImGui::SameLine();
1175 const bool can_save_project =
1177 if (!can_save_project) {
1178 ImGui::BeginDisabled();
1179 }
1180 if (ImGui::Button(ICON_MD_SAVE " Save Project")) {
1181 auto status = SaveProjectSettings();
1182 if (status.ok()) {
1183 status_message_ = has_unpublished_changes
1184 ? "Project saved; minecart track drafts remain "
1185 "unsaved."
1186 : "Project saved.";
1187 show_success_ = true;
1188 } else {
1190 absl::StrFormat("Project save failed: %s", status.message());
1191 show_success_ = false;
1192 }
1193 }
1194 if (!can_save_project) {
1195 ImGui::EndDisabled();
1196 }
1197
1198 ImGui::Separator();
1199
1200 const bool advanced = ImGui::CollapsingHeader("Advanced: raw start table");
1201 if (advanced)
1202 ImGui::TextWrapped(
1203 "World pixels use the room grid origin; there is no added $1000 "
1204 "offset. "
1205 "Raw changes do not relocate placed carts. Use the canvas for paired "
1206 "moves.");
1207 if (advanced &&
1208 ImGui::BeginTable("TracksTable", 7,
1209 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
1210 ImGuiTableFlags_Resizable)) {
1211 ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 30.0f);
1212 ImGui::TableSetupColumn("Room ID", ImGuiTableColumnFlags_WidthFixed,
1213 80.0f);
1214 ImGui::TableSetupColumn("World X", ImGuiTableColumnFlags_WidthFixed,
1215 80.0f);
1216 ImGui::TableSetupColumn("World Y", ImGuiTableColumnFlags_WidthFixed,
1217 80.0f);
1218 ImGui::TableSetupColumn("Pick", ImGuiTableColumnFlags_WidthFixed, 50.0f);
1219 ImGui::TableSetupColumn("Go", ImGuiTableColumnFlags_WidthFixed, 40.0f);
1220 ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed,
1221 60.0f);
1222 ImGui::TableHeadersRow();
1223
1224 for (auto& track : tracks_) {
1225 ImGui::TableNextRow();
1226
1227 const bool is_default = IsDefaultTrack(track);
1228 const bool used_in_rooms =
1229 track.id >= 0 &&
1230 track.id < static_cast<int>(route_slot_used_.size()) &&
1231 route_slot_used_[track.id];
1232 const bool missing_start = used_in_rooms && is_default;
1233
1234 if (missing_start) {
1235 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1236 IM_COL32(120, 40, 40, 120));
1237 } else if (is_default) {
1238 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1239 IM_COL32(60, 60, 60, 80));
1240 }
1241
1242 // Highlight the row being picked
1243 if (picking_mode_ && track.id == picking_track_index_) {
1244 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1245 IM_COL32(80, 80, 0, 100));
1246 }
1247
1248 ImGui::TableNextColumn();
1249 ImGui::Text("%d", track.id);
1250
1251 ImGui::TableNextColumn();
1252 uint16_t room_id = static_cast<uint16_t>(track.room_id);
1254 absl::StrFormat("##Room%d", track.id).c_str(), &room_id,
1255 60.0f)) {
1256 auto next = track;
1257 next.room_id = room_id;
1258 const auto status = UpdateTrack(track.id, next);
1259 if (!status.ok())
1260 status_message_ = std::string(status.message());
1261 }
1262
1263 ImGui::TableNextColumn();
1264 uint16_t start_x = static_cast<uint16_t>(track.start_x);
1266 absl::StrFormat("##StartX%d", track.id).c_str(), &start_x,
1267 60.0f)) {
1268 auto next = track;
1269 next.start_x = start_x;
1270 const auto status = UpdateTrack(track.id, next);
1271 if (!status.ok())
1272 status_message_ = std::string(status.message());
1273 }
1274
1275 ImGui::TableNextColumn();
1276 uint16_t start_y = static_cast<uint16_t>(track.start_y);
1278 absl::StrFormat("##StartY%d", track.id).c_str(), &start_y,
1279 60.0f)) {
1280 auto next = track;
1281 next.start_y = start_y;
1282 const auto status = UpdateTrack(track.id, next);
1283 if (!status.ok())
1284 status_message_ = std::string(status.message());
1285 }
1286
1287 // Pick button to select coordinates from canvas
1288 ImGui::TableNextColumn();
1289 ImGui::PushID(track.id);
1290 bool is_picking_this =
1291 picking_mode_ && picking_track_index_ == track.id;
1292 {
1293 std::optional<gui::StyleColorGuard> pick_guard;
1294 if (is_picking_this) {
1295 pick_guard.emplace(ImGuiCol_Button, ImVec4(0.8f, 0.6f, 0.0f, 1.0f));
1296 }
1297 if (ImGui::SmallButton(ICON_MD_MY_LOCATION)) {
1298 if (is_picking_this) {
1300 } else {
1301 StartCoordinatePicking(track.id);
1302 }
1303 }
1304 }
1305 if (ImGui::IsItemHovered()) {
1306 ImGui::SetTooltip(is_picking_this ? "Cancel picking"
1307 : "Pick coordinates from canvas");
1308 }
1309 ImGui::PopID();
1310
1311 // Go to room button
1312 ImGui::TableNextColumn();
1313 ImGui::PushID(track.id + 1000);
1314 if (ImGui::SmallButton(ICON_MD_ARROW_FORWARD)) {
1316 room_navigation_callback_(track.room_id);
1317 }
1318 }
1319 if (ImGui::IsItemHovered()) {
1320 ImGui::SetTooltip(tr("Navigate to room $%04X"), track.room_id);
1321 }
1322 ImGui::PopID();
1323
1324 // Status column
1325 ImGui::TableNextColumn();
1326 if (missing_start) {
1327 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1329 } else if (is_default) {
1330 ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), ICON_MD_INFO);
1331 } else if (used_in_rooms) {
1332 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1334 } else {
1335 ImGui::Text("-");
1336 }
1337
1338 if (ImGui::IsItemHovered()) {
1339 ImGui::BeginTooltip();
1340 if (missing_start) {
1341 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1342 tr("Referenced by a cart but still default"));
1343 } else if (is_default) {
1344 ImGui::Text(tr("Default filler slot"));
1345 } else if (used_in_rooms) {
1346 ImGui::Text(tr("Referenced by a minecart sprite"));
1347 } else {
1348 ImGui::Text(tr("No route reference detected"));
1349 }
1350
1351 auto rooms_it = route_usage_rooms_.find(track.id);
1352 if (rooms_it != route_usage_rooms_.end()) {
1353 ImGui::Separator();
1354 ImGui::Text(tr("Rooms:"));
1355 for (int room_id : rooms_it->second) {
1356 ImGui::BulletText(tr("0x%03X"), room_id);
1357 }
1358 }
1359 ImGui::EndTooltip();
1360 }
1361 }
1362
1363 ImGui::EndTable();
1364 }
1365
1366 // Summary + room audit
1367 int default_count = 0;
1368 int used_count = 0;
1369 int missing_start_count = 0;
1370 for (const auto& track : tracks_) {
1371 bool is_default = IsDefaultTrack(track);
1372 bool used_in_rooms =
1373 track.id >= 0 &&
1374 track.id < static_cast<int>(route_slot_used_.size()) &&
1375 route_slot_used_[track.id];
1376 if (is_default) {
1377 default_count++;
1378 }
1379 if (used_in_rooms) {
1380 used_count++;
1381 }
1382 if (used_in_rooms && is_default) {
1383 missing_start_count++;
1384 }
1385 }
1386
1387 ImGui::Separator();
1388 ImGui::Text(tr("%s route slots: used %d/%d, default %d, missing starts %d"),
1389 audit_includes_all_rooms_ ? "Project" : "Loaded-room",
1390 used_count, kTrackSlotCount, default_count,
1391 missing_start_count);
1392 ImGui::TextDisabled(
1393 tr("Route usage comes from minecart sprite subtypes, not visual track "
1394 "piece subtypes."));
1395 ImGui::EndTabItem();
1396 }
1397
1398 if (ImGui::BeginTabItem(tr("Collision"))) {
1399 ImGui::TextDisabled(
1400 tr("Audit room collision, preview generated changes, then apply them "
1401 "as one undoable dungeon edit."));
1402
1403 int rooms_needing_collision = 0;
1404 int protected_rooms = 0;
1405 for (const auto& [room_id, audit] : room_audit_) {
1406 if (!audit.track_subtypes.empty() && !audit.has_any_custom_collision) {
1407 ++rooms_needing_collision;
1408 } else if (!audit.track_subtypes.empty() &&
1409 audit.has_any_custom_collision) {
1410 ++protected_rooms;
1411 }
1412 }
1413 ImGui::TextDisabled(tr("%s audit: %d room(s) need collision."),
1414 audit_includes_all_rooms_ ? "Project" : "Loaded-room",
1415 rooms_needing_collision);
1416
1417 if (collision_preview_.empty()) {
1418 if (ImGui::Button(ICON_MD_PREVIEW " Preview All Rooms")) {
1419 const absl::Status status = BuildAllEligibleCollisionPreview();
1420 status_message_ = status.ok()
1421 ? absl::StrFormat("Preview ready for %d rooms.",
1422 collision_preview_.size())
1423 : std::string(status.message());
1424 show_success_ = status.ok();
1425 }
1426 ImGui::TextDisabled(
1427 tr("Scans all %d rooms. The list below otherwise reflects loaded "
1428 "rooms only."),
1429 static_cast<int>(rooms_->size()));
1430 } else {
1431 int preview_tiles = 0;
1432 for (const auto& result : collision_preview_) {
1433 preview_tiles += result.tiles_generated;
1434 }
1435 ImGui::Text(ICON_MD_PREVIEW " Preview: %zu rooms, %d collision tiles",
1436 collision_preview_.size(), preview_tiles);
1438 ImGui::BeginDisabled();
1439 }
1440 if (ImGui::Button(absl::StrFormat(ICON_MD_CHECK_CIRCLE
1441 " Apply Preview to %d Rooms",
1442 collision_preview_.size())
1443 .c_str())) {
1444 ImGui::OpenPopup("Confirm Minecart Collision Apply");
1445 }
1447 ImGui::EndDisabled();
1448 }
1449 ImGui::SameLine();
1450 if (ImGui::Button(ICON_MD_CANCEL " Discard Preview")) {
1452 status_message_ = "Collision preview discarded.";
1453 show_success_ = true;
1454 }
1455
1456 if (ImGui::BeginPopupModal("Confirm Minecart Collision Apply", nullptr,
1457 ImGuiWindowFlags_AlwaysAutoResize)) {
1458 ImGui::Text(tr("Apply generated collision to %zu rooms?"),
1459 collision_preview_.size());
1460 ImGui::TextDisabled(tr("%d collision tiles will be added."),
1461 preview_tiles);
1462 ImGui::TextDisabled(
1463 tr("This changes dungeon room models as one undoable edit. ROM "
1464 "bytes change only when you save."));
1465 if (ImGui::Button(ICON_MD_CHECK_CIRCLE " Apply Changes")) {
1466 const int applied_rooms = static_cast<int>(collision_preview_.size());
1467 const absl::Status status = ApplyCollisionPreview();
1469 status.ok()
1470 ? absl::StrFormat(
1471 "Applied collision to %d rooms. Save the ROM to "
1472 "publish it.",
1473 applied_rooms)
1474 : std::string(status.message());
1475 show_success_ = status.ok();
1476 ImGui::CloseCurrentPopup();
1477 }
1478 ImGui::SameLine();
1479 if (ImGui::Button(ICON_MD_CANCEL " Cancel")) {
1480 ImGui::CloseCurrentPopup();
1481 }
1482 ImGui::EndPopup();
1483 }
1484 }
1485
1486 if (protected_rooms > 0) {
1487 ImGui::TextDisabled(
1488 tr("%d room(s) with existing custom collision are protected and "
1489 "excluded."),
1490 protected_rooms);
1491 }
1492
1493 ImGui::BeginChild("##TrackAuditRooms", ImVec2(0, 160), true);
1494 std::vector<int> audited_room_ids;
1495 audited_room_ids.reserve(room_audit_.size());
1496 for (const auto& [room_id, audit] : room_audit_) {
1497 audited_room_ids.push_back(room_id);
1498 }
1499 std::sort(audited_room_ids.begin(), audited_room_ids.end());
1500 for (int room_id : audited_room_ids) {
1501 const auto& audit = room_audit_.at(room_id);
1502
1503 // Status icon
1504 if (!audit.has_track_collision && audit.has_any_custom_collision) {
1505 ImGui::TextColored(
1506 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1508 " Room 0x%03X (existing custom collision; protected)",
1509 room_id);
1510 } else if (!audit.has_track_collision) {
1511 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.1f, 1.0f),
1512 ICON_MD_ERROR " Room 0x%03X (no collision)",
1513 room_id);
1514 } else if (!audit.has_minecart_on_stop) {
1515 ImGui::TextColored(
1516 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1517 ICON_MD_WARNING_AMBER " Room 0x%03X (no cart on stop)", room_id);
1518 } else {
1519 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1520 ICON_MD_CHECK_CIRCLE " Room 0x%03X", room_id);
1521 }
1522
1523 ImGui::SameLine();
1524 ImGui::PushID(room_id);
1525 if (ImGui::SmallButton(ICON_MD_ARROW_FORWARD)) {
1528 }
1529 }
1530 if (ImGui::IsItemHovered()) {
1531 ImGui::SetTooltip(tr("Navigate to room 0x%03X"), room_id);
1532 }
1533
1534 // Preview only; applying is a separate, explicit batch action above.
1535 if (rooms_ && !audit.track_subtypes.empty() &&
1536 !audit.has_any_custom_collision) {
1537 ImGui::SameLine();
1538 if (ImGui::SmallButton(
1539 absl::StrFormat(ICON_MD_PREVIEW " Preview##%d", room_id)
1540 .c_str())) {
1541 const absl::Status status = BuildCollisionPreview({room_id});
1543 status.ok()
1544 ? absl::StrFormat("Preview ready for room 0x%03X.", room_id)
1545 : std::string(status.message());
1546 show_success_ = status.ok();
1547 }
1548 if (ImGui::IsItemHovered()) {
1549 ImGui::SetTooltip(
1550 tr("Preview generated collision without changing the room"));
1551 }
1552 }
1553
1554 ImGui::PopID();
1555 }
1556 ImGui::EndChild();
1558 ImGui::EndTabItem();
1559 }
1560
1561 ImGui::EndTabBar();
1562
1563 if (!status_message_.empty() && !picking_mode_) {
1564 ImGui::Separator();
1565 ImGui::TextColored(show_success_ ? ImVec4(0, 1, 0, 1) : ImVec4(1, 0, 0, 1),
1566 "%s", status_message_.c_str());
1567 }
1568}
1569
1571 load_attempted_ = true;
1572
1573 if (project_ == nullptr || !project_->hack_manifest.loaded() ||
1575 return absl::FailedPreconditionError(
1576 "Hack manifest does not define minecart_tracks.source");
1577 }
1578 const core::MinecartTrackLayout::Source source_identity =
1580
1581 std::filesystem::path source_path;
1583 std::string source_bytes;
1584 ASSIGN_OR_RETURN(source_bytes, ReadSourceFile(source_path));
1585 auto document_or =
1586 MinecartTrackSourceDocument::Parse(std::move(source_bytes));
1587 if (!document_or.ok()) {
1588 return document_or.status();
1589 }
1590 if (!project_->hack_manifest.minecart_track_layout().source.has_value() ||
1592 source_identity) {
1593 return absl::AbortedError(
1594 "Hack manifest minecart_tracks.source changed while loading tracks");
1595 }
1596
1597 std::vector<MinecartTrack> candidate_tracks = document_or->tracks();
1598 const std::string source_sha256 =
1599 core::ComputeSourceArtifactSha256(document_or->source_bytes());
1600
1601 // An exact reload does not replace Undo's source. Publishing already rebases
1602 // these fields without invalidating paired cart actions. A changed document
1603 // (including non-track bytes), descriptor, or path must still reject replay.
1604 const bool same_source =
1605 loaded_ && loaded_source_identity_ == source_identity &&
1606 loaded_source_path_ == source_path &&
1607 loaded_source_sha256_ == source_sha256 && tracks_ == candidate_tracks;
1608 if (!same_source) {
1610 }
1612 tracks_ = candidate_tracks;
1613 loaded_tracks_ = std::move(candidate_tracks);
1614 source_document_ = std::move(*document_or);
1615 loaded_source_identity_ = source_identity;
1616 loaded_source_path_ = std::move(source_path);
1617 loaded_source_sha256_ = source_sha256;
1618 loaded_ = true;
1619 audit_dirty_ = true;
1620 status_message_.clear();
1621 show_success_ = true;
1622 return absl::OkStatus();
1623}
1624
1626 if (!loaded_ || !source_document_.has_value() ||
1627 !loaded_source_identity_.has_value() || loaded_source_path_.empty() ||
1628 loaded_source_sha256_.empty()) {
1629 return absl::FailedPreconditionError("Minecart tracks are not loaded");
1630 }
1631 if (!HasUnpublishedChanges()) {
1632 return absl::FailedPreconditionError(
1633 "No unpublished minecart track drafts to publish");
1634 }
1635#if defined(__EMSCRIPTEN__)
1636 return absl::FailedPreconditionError(
1637 "Minecart source publishing is unavailable in browser builds because "
1638 "durable atomic filesystem publication cannot be guaranteed; drafts "
1639 "were kept");
1640#else
1643
1644 std::filesystem::path source_path;
1646 if (source_path != loaded_source_path_) {
1647 return absl::FailedPreconditionError(
1648 "Minecart source path changed after the tracks were loaded; drafts "
1649 "were kept");
1650 }
1651
1652 std::unique_ptr<core::SourceArtifactPublicationLock> publication_lock;
1653 ASSIGN_OR_RETURN(publication_lock,
1655 {source_path}, kMinecartSourcePublisherLabels));
1656
1657 // Recheck every source identity after acquiring the durable publication
1658 // lock. A manifest reload or path replacement must fail before mutation.
1660 std::filesystem::path locked_source_path;
1661 ASSIGN_OR_RETURN(locked_source_path, ResolveTrackSourcePath());
1662 if (locked_source_path != loaded_source_path_) {
1663 return absl::FailedPreconditionError(
1664 "Minecart source path changed while acquiring its publication lock; "
1665 "drafts were kept");
1666 }
1667
1668 std::string source_before;
1669 ASSIGN_OR_RETURN(source_before, ReadSourceFile(locked_source_path));
1670 const std::string source_sha256_before =
1671 core::ComputeSourceArtifactSha256(source_before);
1672 if (source_sha256_before != loaded_source_sha256_ ||
1673 source_before != source_document_->source_bytes()) {
1674 return absl::AbortedError(absl::StrFormat(
1675 "Minecart source SHA-256 CAS failed: expected %s, got %s; drafts were "
1676 "kept",
1677 loaded_source_sha256_, source_sha256_before));
1678 }
1679
1680 std::string source_after;
1681 ASSIGN_OR_RETURN(source_after, source_document_->Render(tracks_));
1682 auto published_document_or = MinecartTrackSourceDocument::Parse(source_after);
1683 if (!published_document_or.ok()) {
1684 return absl::DataLossError(
1685 absl::StrFormat("Rendered minecart source failed strict validation: %s",
1686 published_document_or.status().message()));
1687 }
1688 if (published_document_or->tracks() != tracks_) {
1689 return absl::DataLossError(
1690 "Rendered minecart source did not reproduce the draft tracks");
1691 }
1692 const std::string source_sha256_after =
1694 const std::vector<MinecartTrack> draft_tracks = tracks_;
1695
1696 std::vector<core::SourceArtifactUpdate> updates;
1697 updates.push_back(core::SourceArtifactUpdate{
1698 .target = locked_source_path,
1699 .before = source_before,
1700 .after = source_after,
1701 });
1703 *publication_lock, std::move(updates), loaded_source_sha256_,
1704 [&]() -> absl::Status {
1705 std::string reopened_source;
1706 ASSIGN_OR_RETURN(reopened_source, ReadSourceFile(locked_source_path));
1707 if (reopened_source != source_after ||
1708 core::ComputeSourceArtifactSha256(reopened_source) !=
1709 source_sha256_after) {
1710 return absl::DataLossError(
1711 "Published minecart source failed exact SHA-256 readback");
1712 }
1713 auto reopened_document_or =
1714 MinecartTrackSourceDocument::Parse(std::move(reopened_source));
1715 if (!reopened_document_or.ok()) {
1716 return absl::DataLossError(absl::StrFormat(
1717 "Published minecart source failed strict readback validation: "
1718 "%s",
1719 reopened_document_or.status().message()));
1720 }
1721 if (reopened_document_or->tracks() != draft_tracks) {
1722 return absl::DataLossError(
1723 "Published minecart source track readback did not match the "
1724 "draft");
1725 }
1726 return absl::OkStatus();
1727 }));
1728
1729 tracks_ = published_document_or->tracks();
1731 paired_track_draft_ = false;
1732 source_document_ = std::move(*published_document_or);
1733 loaded_source_path_ = std::move(locked_source_path);
1734 loaded_source_sha256_ = source_sha256_after;
1736 audit_dirty_ = true;
1737 return absl::OkStatus();
1738#endif
1739}
1740
1741} // namespace yaze::editor
bool is_loaded() const
Definition rom.h:187
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::Status RestoreTrackDraft(uint64_t generation, const MinecartTrack &expected, const MinecartTrack &replacement)
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 MoveCartStart(const MinecartTrack &before, const MinecartTrack &after)
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)
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:655
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:94
std::vector< uint16_t > track_object_ids
Definition project.h:101
std::vector< uint16_t > minecart_sprite_ids
Definition project.h:102
std::vector< uint16_t > track_stop_tiles
Definition project.h:97
std::vector< uint16_t > track_tiles
Definition project.h:96
std::vector< uint16_t > track_switch_tiles
Definition project.h:98
Modern project structure with comprehensive settings consolidation.
Definition project.h:287
bool project_opened() const
Definition project.h:505
core::HackManifest hack_manifest
Definition project.h:360
DungeonOverlaySettings dungeon_overlay
Definition project.h:347
std::array< uint8_t, 64 *64 > tiles