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