259 return absl::FailedPreconditionError(
260 "Project descriptor moved; reload minecart tracks to rebind the "
264 return absl::FailedPreconditionError(
265 "An open project descriptor is required for minecart tracks");
268 return absl::FailedPreconditionError(
269 "A loaded hack manifest is required for minecart tracks");
272 if (!source.has_value()) {
273 return absl::FailedPreconditionError(
274 "Hack manifest does not define minecart_tracks.source");
279 if (descriptor_path.is_relative()) {
280 descriptor_path = std::filesystem::absolute(descriptor_path, ec);
282 return absl::InvalidArgumentError(absl::StrFormat(
283 "Could not resolve project descriptor path: %s", ec.message()));
287 const std::filesystem::path project_root =
288 std::filesystem::weakly_canonical(descriptor_path.parent_path(), ec);
289 if (ec || project_root.empty() ||
290 !std::filesystem::is_directory(project_root, ec) || ec) {
291 return absl::InvalidArgumentError(
292 "Project descriptor parent is not an existing directory");
295 const std::filesystem::path candidate =
296 (project_root / source->path).lexically_normal();
297 const std::filesystem::file_status candidate_status =
298 std::filesystem::symlink_status(candidate, ec);
300 return absl::NotFoundError(
301 absl::StrFormat(
"Minecart source not found: %s", candidate.string()));
303 if (std::filesystem::is_symlink(candidate_status)) {
304 return absl::PermissionDeniedError(
305 "Minecart source may not be a symbolic link");
307 const std::filesystem::path resolved =
308 std::filesystem::canonical(candidate, ec);
310 return absl::NotFoundError(
311 absl::StrFormat(
"Minecart source not found: %s", candidate.string()));
313 if (!IsStrictDescendant(resolved, project_root)) {
314 return absl::PermissionDeniedError(
315 "Minecart source resolves outside the project root");
317 if (!std::filesystem::is_regular_file(resolved, ec) || ec) {
318 return absl::InvalidArgumentError(
319 "Minecart source must be an existing regular file");
759 std::array<bool, 256> track_tiles{};
760 std::array<bool, 256> stop_tiles{};
761 std::array<bool, 256> switch_tiles{};
762 auto apply_list = [](std::array<bool, 256>& dest,
763 const std::vector<uint16_t>& values) {
765 for (uint16_t value : values) {
766 if (value < dest.size()) {
775 std::vector<uint16_t> default_track_tiles;
776 for (uint16_t tile = 0xB0; tile <= 0xBE; ++tile) {
777 default_track_tiles.push_back(tile);
779 apply_list(track_tiles, default_track_tiles);
785 apply_list(stop_tiles, {0xB7, 0xB8, 0xB9, 0xBA});
791 apply_list(switch_tiles, {0xD0, 0xD1, 0xD2, 0xD3});
794 std::vector<uint16_t> track_object_ids = {0x31};
795 std::vector<uint16_t> minecart_sprite_ids = {0xA3};
805 std::unordered_map<int, bool> track_object_id_map;
806 for (uint16_t
id : track_object_ids) {
807 track_object_id_map[
static_cast<int>(id)] =
true;
809 std::unordered_map<int, bool> minecart_sprite_id_map;
810 for (uint16_t
id : minecart_sprite_ids) {
811 minecart_sprite_id_map[
static_cast<int>(id)] =
true;
814 auto audit_room = [&](
int room_id,
zelda3::Room& room) {
817 room.EnsureObjectsLoaded();
818 room.EnsureSpritesLoaded();
820 std::array<bool, kTrackSlotCount> seen_subtype{};
822 for (
const auto& obj : room.GetTileObjects()) {
823 if (!track_object_id_map[
static_cast<int>(obj.id_)]) {
826 int subtype = obj.size_ & 0x1F;
828 subtype >= 0 && subtype < kTrackSlotCount) {
829 if (!seen_subtype[
static_cast<size_t>(subtype)]) {
830 seen_subtype[
static_cast<size_t>(subtype)] =
true;
836 std::unordered_map<int, bool> stop_positions;
837 const auto& collision = room.custom_collision();
840 const auto& map = collision.tiles;
841 for (
int y = 0; y < 64; ++y) {
842 for (
int x = 0; x < 64; ++x) {
843 uint8_t tile = map[
static_cast<size_t>(y * 64 + x)];
844 if (track_tiles[tile] || stop_tiles[tile] || switch_tiles[tile]) {
847 if (stop_tiles[tile]) {
849 stop_positions[y * 64 + x] =
true;
855 std::array<bool, kTrackSlotCount> seen_route_slot{};
856 for (
const auto& sprite : room.GetSprites()) {
857 if (!minecart_sprite_id_map[
static_cast<int>(sprite.id())]) {
861 const int route_slot = sprite.subtype();
862 if (route_slot >= 0 && route_slot < kTrackSlotCount &&
863 !seen_route_slot[
static_cast<size_t>(route_slot)]) {
864 seen_route_slot[
static_cast<size_t>(route_slot)] =
true;
869 int tile_x = sprite.x() * 2;
870 int tile_y = sprite.y() * 2;
871 if (tile_x >= 0 && tile_x < 64 && tile_y >= 0 && tile_y < 64) {
872 int idx = tile_y * 64 + tile_x;
873 if (stop_positions[idx]) {
888 for (
int room_id = 0; room_id < static_cast<int>(
rooms_->
size()); ++room_id) {
890 audit_room(room_id, *room);
893 if (!include_unmaterialized) {
900 audit_room(room_id, room);
923 const std::vector<int>& room_ids) {
925 return absl::FailedPreconditionError(
"Dungeon rooms are unavailable");
927 if (room_ids.empty()) {
928 return absl::InvalidArgumentError(
"No eligible rooms to preview");
932 std::vector<int> sorted_room_ids = room_ids;
933 std::sort(sorted_room_ids.begin(), sorted_room_ids.end());
934 if (std::adjacent_find(sorted_room_ids.begin(), sorted_room_ids.end()) !=
935 sorted_room_ids.end()) {
936 return absl::InvalidArgumentError(
937 "Collision preview contains a duplicate room ID");
940 std::vector<zelda3::TrackCollisionResult> preview;
941 preview.reserve(sorted_room_ids.size());
942 for (
int room_id : sorted_room_ids) {
943 if (room_id < 0 || room_id >=
static_cast<int>(
rooms_->
size())) {
944 return absl::OutOfRangeError(absl::StrFormat(
945 "Collision preview room 0x%03X is out of range", room_id));
948 if (room ==
nullptr) {
950 return absl::FailedPreconditionError(absl::StrFormat(
951 "Collision preview room 0x%03X cannot be loaded without a ROM",
954 auto& materialized = (*rooms_)[room_id];
957 room = &materialized;
959 room->EnsureObjectsLoaded();
960 if (HasAnyCustomCollision(room->custom_collision())) {
961 return absl::FailedPreconditionError(absl::StrFormat(
962 "Room 0x%03X already has custom collision; generation will not "
969 generated.room_id = room_id;
970 if (!generated.collision_map.has_data || generated.tiles_generated <= 0) {
971 return absl::FailedPreconditionError(absl::StrFormat(
972 "Room 0x%03X has no supported minecart track pieces", room_id));
974 preview.push_back(std::move(generated));
979 return absl::OkStatus();
1015 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1016 tr(
"Open a project to edit minecart tracks."));
1021 if (!binding_status.ok()) {
1026 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1027 tr(
"Open a project to edit minecart tracks."));
1043 ImGui::Text(tr(
"Minecart Track Editor"));
1049 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f),
1053 if (!ImGui::BeginTabBar(
"##MinecartTasks")) {
1057 if (ImGui::BeginTabItem(tr(
"Routes"))) {
1058 ImGui::TextDisabled(
1059 tr(
"Edit route start slots and publish the manifest-owned ASM "
1061#if defined(__EMSCRIPTEN__)
1062 ImGui::TextDisabled(
1063 tr(
"Source publishing is unavailable in browser builds; drafts are "
1067 const bool can_publish =
1068 has_unpublished_changes && kSourcePublishingAvailable;
1070 ImGui::BeginDisabled();
1076 ?
"Minecart ASM source published only. Save pending dungeon/ROM "
1077 "edits to the development ROM, rebuild the patched ROM, then "
1078 "reopen/reload the patched ROM in Yaze before testing."
1079 : std::string(status.message());
1083 ImGui::EndDisabled();
1086 if (!has_unpublished_changes) {
1087 ImGui::BeginDisabled();
1092 : std::string(status.message());
1095 if (!has_unpublished_changes) {
1096 ImGui::EndDisabled();
1102 : std::string(status.message());
1106 const bool can_save_project =
1108 if (!can_save_project) {
1109 ImGui::BeginDisabled();
1115 ?
"Project saved; minecart track drafts remain "
1121 absl::StrFormat(
"Project save failed: %s", status.message());
1125 if (!can_save_project) {
1126 ImGui::EndDisabled();
1132 ImGui::TextDisabled(tr(
1133 "Camera coordinates use $1XXX format (base $1000 + room offset + local "
1135 ImGui::TextDisabled(
1136 tr(
"Hover over dungeon canvas to see coordinates, or click 'Pick' "
1140 if (ImGui::BeginTable(
"TracksTable", 7,
1141 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
1142 ImGuiTableFlags_Resizable)) {
1143 ImGui::TableSetupColumn(
"ID", ImGuiTableColumnFlags_WidthFixed, 30.0f);
1144 ImGui::TableSetupColumn(
"Room ID", ImGuiTableColumnFlags_WidthFixed,
1146 ImGui::TableSetupColumn(
"Camera X", ImGuiTableColumnFlags_WidthFixed,
1148 ImGui::TableSetupColumn(
"Camera Y", ImGuiTableColumnFlags_WidthFixed,
1150 ImGui::TableSetupColumn(
"Pick", ImGuiTableColumnFlags_WidthFixed, 50.0f);
1151 ImGui::TableSetupColumn(
"Go", ImGuiTableColumnFlags_WidthFixed, 40.0f);
1152 ImGui::TableSetupColumn(
"Status", ImGuiTableColumnFlags_WidthFixed,
1154 ImGui::TableHeadersRow();
1157 ImGui::TableNextRow();
1160 const bool used_in_rooms =
1164 const bool missing_start = used_in_rooms && is_default;
1166 if (missing_start) {
1167 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1168 IM_COL32(120, 40, 40, 120));
1169 }
else if (is_default) {
1170 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1171 IM_COL32(60, 60, 60, 80));
1176 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1177 IM_COL32(80, 80, 0, 100));
1180 ImGui::TableNextColumn();
1181 ImGui::Text(
"%d", track.id);
1183 ImGui::TableNextColumn();
1184 uint16_t room_id =
static_cast<uint16_t
>(track.room_id);
1186 absl::StrFormat(
"##Room%d", track.id).c_str(), &room_id,
1188 track.room_id = room_id;
1192 ImGui::TableNextColumn();
1193 uint16_t start_x =
static_cast<uint16_t
>(track.start_x);
1195 absl::StrFormat(
"##StartX%d", track.id).c_str(), &start_x,
1197 track.start_x = start_x;
1201 ImGui::TableNextColumn();
1202 uint16_t start_y =
static_cast<uint16_t
>(track.start_y);
1204 absl::StrFormat(
"##StartY%d", track.id).c_str(), &start_y,
1206 track.start_y = start_y;
1211 ImGui::TableNextColumn();
1212 ImGui::PushID(track.id);
1213 bool is_picking_this =
1216 std::optional<gui::StyleColorGuard> pick_guard;
1217 if (is_picking_this) {
1218 pick_guard.emplace(ImGuiCol_Button, ImVec4(0.8f, 0.6f, 0.0f, 1.0f));
1221 if (is_picking_this) {
1228 if (ImGui::IsItemHovered()) {
1229 ImGui::SetTooltip(is_picking_this ?
"Cancel picking"
1230 :
"Pick coordinates from canvas");
1235 ImGui::TableNextColumn();
1236 ImGui::PushID(track.id + 1000);
1242 if (ImGui::IsItemHovered()) {
1243 ImGui::SetTooltip(tr(
"Navigate to room $%04X"), track.room_id);
1248 ImGui::TableNextColumn();
1249 if (missing_start) {
1250 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1252 }
else if (is_default) {
1253 ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f),
ICON_MD_INFO);
1254 }
else if (used_in_rooms) {
1255 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1261 if (ImGui::IsItemHovered()) {
1262 ImGui::BeginTooltip();
1263 if (missing_start) {
1264 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1265 tr(
"Referenced by a cart but still default"));
1266 }
else if (is_default) {
1267 ImGui::Text(tr(
"Default filler slot"));
1268 }
else if (used_in_rooms) {
1269 ImGui::Text(tr(
"Referenced by a minecart sprite"));
1271 ImGui::Text(tr(
"No route reference detected"));
1277 ImGui::Text(tr(
"Rooms:"));
1278 for (
int room_id : rooms_it->second) {
1279 ImGui::BulletText(tr(
"0x%03X"), room_id);
1282 ImGui::EndTooltip();
1290 int default_count = 0;
1292 int missing_start_count = 0;
1293 for (
const auto& track :
tracks_) {
1295 bool used_in_rooms =
1302 if (used_in_rooms) {
1305 if (used_in_rooms && is_default) {
1306 missing_start_count++;
1311 ImGui::Text(tr(
"%s route slots: used %d/%d, default %d, missing starts %d"),
1313 used_count, kTrackSlotCount, default_count,
1314 missing_start_count);
1315 ImGui::TextDisabled(
1316 tr(
"Route usage comes from minecart sprite subtypes, not visual track "
1317 "piece subtypes."));
1318 ImGui::EndTabItem();
1321 if (ImGui::BeginTabItem(tr(
"Collision"))) {
1322 ImGui::TextDisabled(
1323 tr(
"Audit room collision, preview generated changes, then apply them "
1324 "as one undoable dungeon edit."));
1326 int rooms_needing_collision = 0;
1327 int protected_rooms = 0;
1328 for (
const auto& [room_id, audit] :
room_audit_) {
1329 if (!audit.track_subtypes.empty() && !audit.has_any_custom_collision) {
1330 ++rooms_needing_collision;
1331 }
else if (!audit.track_subtypes.empty() &&
1332 audit.has_any_custom_collision) {
1336 ImGui::TextDisabled(tr(
"%s audit: %d room(s) need collision."),
1338 rooms_needing_collision);
1344 ? absl::StrFormat(
"Preview ready for %d rooms.",
1346 : std::string(status.message());
1349 ImGui::TextDisabled(
1350 tr(
"Scans all %d rooms. The list below otherwise reflects loaded "
1354 int preview_tiles = 0;
1356 preview_tiles += result.tiles_generated;
1361 ImGui::BeginDisabled();
1364 " Apply Preview to %d Rooms",
1367 ImGui::OpenPopup(
"Confirm Minecart Collision Apply");
1370 ImGui::EndDisabled();
1379 if (ImGui::BeginPopupModal(
"Confirm Minecart Collision Apply",
nullptr,
1380 ImGuiWindowFlags_AlwaysAutoResize)) {
1381 ImGui::Text(tr(
"Apply generated collision to %d rooms?"),
1383 ImGui::TextDisabled(tr(
"%d collision tiles will be added."),
1385 ImGui::TextDisabled(
1386 tr(
"This changes dungeon room models as one undoable edit. ROM "
1387 "bytes change only when you save."));
1394 "Applied collision to %d rooms. Save the ROM to "
1397 : std::string(status.message());
1399 ImGui::CloseCurrentPopup();
1403 ImGui::CloseCurrentPopup();
1409 if (protected_rooms > 0) {
1410 ImGui::TextDisabled(
1411 tr(
"%d room(s) with existing custom collision are protected and "
1416 ImGui::BeginChild(
"##TrackAuditRooms", ImVec2(0, 160),
true);
1417 std::vector<int> audited_room_ids;
1419 for (
const auto& [room_id, audit] :
room_audit_) {
1420 audited_room_ids.push_back(room_id);
1422 std::sort(audited_room_ids.begin(), audited_room_ids.end());
1423 for (
int room_id : audited_room_ids) {
1427 if (!audit.has_track_collision && audit.has_any_custom_collision) {
1429 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1431 " Room 0x%03X (existing custom collision; protected)",
1433 }
else if (!audit.has_track_collision) {
1434 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.1f, 1.0f),
1437 }
else if (!audit.has_minecart_on_stop) {
1439 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1442 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1447 ImGui::PushID(room_id);
1453 if (ImGui::IsItemHovered()) {
1454 ImGui::SetTooltip(tr(
"Navigate to room 0x%03X"), room_id);
1458 if (
rooms_ && !audit.track_subtypes.empty() &&
1459 !audit.has_any_custom_collision) {
1461 if (ImGui::SmallButton(
1467 ? absl::StrFormat(
"Preview ready for room 0x%03X.", room_id)
1468 : std::string(status.message());
1471 if (ImGui::IsItemHovered()) {
1473 tr(
"Preview generated collision without changing the room"));
1481 ImGui::EndTabItem();
1488 ImGui::TextColored(
show_success_ ? ImVec4(0, 1, 0, 1) : ImVec4(1, 0, 0, 1),
1541 return absl::FailedPreconditionError(
"Minecart tracks are not loaded");
1544 return absl::FailedPreconditionError(
1545 "No unpublished minecart track drafts to publish");
1547#if defined(__EMSCRIPTEN__)
1548 return absl::FailedPreconditionError(
1549 "Minecart source publishing is unavailable in browser builds because "
1550 "durable atomic filesystem publication cannot be guaranteed; drafts "
1556 std::filesystem::path source_path;
1559 return absl::FailedPreconditionError(
1560 "Minecart source path changed after the tracks were loaded; drafts "
1564 std::unique_ptr<core::SourceArtifactPublicationLock> publication_lock;
1567 {source_path}, kMinecartSourcePublisherLabels));
1572 std::filesystem::path locked_source_path;
1575 return absl::FailedPreconditionError(
1576 "Minecart source path changed while acquiring its publication lock; "
1577 "drafts were kept");
1580 std::string source_before;
1582 const std::string source_sha256_before =
1586 return absl::AbortedError(absl::StrFormat(
1587 "Minecart source SHA-256 CAS failed: expected %s, got %s; drafts were "
1592 std::string source_after;
1595 if (!published_document_or.ok()) {
1596 return absl::DataLossError(
1597 absl::StrFormat(
"Rendered minecart source failed strict validation: %s",
1598 published_document_or.status().message()));
1600 if (published_document_or->tracks() !=
tracks_) {
1601 return absl::DataLossError(
1602 "Rendered minecart source did not reproduce the draft tracks");
1604 const std::string source_sha256_after =
1606 const std::vector<MinecartTrack> draft_tracks =
tracks_;
1608 std::vector<core::SourceArtifactUpdate> updates;
1610 .
target = locked_source_path,
1611 .before = source_before,
1612 .after = source_after,
1616 [&]() -> absl::Status {
1617 std::string reopened_source;
1619 if (reopened_source != source_after ||
1621 source_sha256_after) {
1622 return absl::DataLossError(
1623 "Published minecart source failed exact SHA-256 readback");
1625 auto reopened_document_or =
1627 if (!reopened_document_or.ok()) {
1628 return absl::DataLossError(absl::StrFormat(
1629 "Published minecart source failed strict readback validation: "
1631 reopened_document_or.status().message()));
1633 if (reopened_document_or->tracks() != draft_tracks) {
1634 return absl::DataLossError(
1635 "Published minecart source track readback did not match the "
1638 return absl::OkStatus();
1641 tracks_ = published_document_or->tracks();
1648 return absl::OkStatus();