266 return absl::FailedPreconditionError(
267 "Project descriptor moved; reload minecart tracks to rebind the "
271 return absl::FailedPreconditionError(
272 "An open project descriptor is required for minecart tracks");
275 return absl::FailedPreconditionError(
276 "A loaded hack manifest is required for minecart tracks");
279 if (!source.has_value()) {
280 return absl::FailedPreconditionError(
281 "Hack manifest does not define minecart_tracks.source");
286 if (descriptor_path.is_relative()) {
287 descriptor_path = std::filesystem::absolute(descriptor_path, ec);
289 return absl::InvalidArgumentError(absl::StrFormat(
290 "Could not resolve project descriptor path: %s", ec.message()));
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");
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);
307 return absl::NotFoundError(
308 absl::StrFormat(
"Minecart source not found: %s", candidate.string()));
310 if (std::filesystem::is_symlink(candidate_status)) {
311 return absl::PermissionDeniedError(
312 "Minecart source may not be a symbolic link");
314 const std::filesystem::path resolved =
315 std::filesystem::canonical(candidate, ec);
317 return absl::NotFoundError(
318 absl::StrFormat(
"Minecart source not found: %s", candidate.string()));
320 if (!IsStrictDescendant(resolved, project_root)) {
321 return absl::PermissionDeniedError(
322 "Minecart source resolves outside the project root");
324 if (!std::filesystem::is_regular_file(resolved, ec) || ec) {
325 return absl::InvalidArgumentError(
326 "Minecart source must be an existing regular file");
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) {
787 for (uint16_t value : values) {
788 if (value < dest.size()) {
797 std::vector<uint16_t> default_track_tiles;
798 for (uint16_t tile = 0xB0; tile <= 0xBE; ++tile) {
799 default_track_tiles.push_back(tile);
801 apply_list(track_tiles, default_track_tiles);
807 apply_list(stop_tiles, {0xB7, 0xB8, 0xB9, 0xBA});
813 apply_list(switch_tiles, {0xD0, 0xD1, 0xD2, 0xD3});
816 std::vector<uint16_t> track_object_ids = {0x31};
817 std::vector<uint16_t> minecart_sprite_ids = {0xA3};
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;
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;
836 auto audit_room = [&](
int room_id,
zelda3::Room& room) {
839 room.EnsureObjectsLoaded();
840 room.EnsureSpritesLoaded();
842 std::array<bool, kTrackSlotCount> seen_subtype{};
844 for (
const auto& obj : room.GetTileObjects()) {
845 if (!track_object_id_map[
static_cast<int>(obj.id_)]) {
848 int subtype = obj.size_ & 0x1F;
850 subtype >= 0 && subtype < kTrackSlotCount) {
851 if (!seen_subtype[
static_cast<size_t>(subtype)]) {
852 seen_subtype[
static_cast<size_t>(subtype)] =
true;
858 std::unordered_map<int, bool> stop_positions;
859 const auto& collision = room.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]) {
869 if (stop_tiles[tile]) {
871 stop_positions[y * 64 + x] =
true;
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())]) {
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;
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]) {
910 for (
int room_id = 0; room_id < static_cast<int>(
rooms_->
size()); ++room_id) {
912 audit_room(room_id, *room);
915 if (!include_unmaterialized) {
922 audit_room(room_id, room);
945 const std::vector<int>& room_ids) {
947 return absl::FailedPreconditionError(
"Dungeon rooms are unavailable");
949 if (room_ids.empty()) {
950 return absl::InvalidArgumentError(
"No eligible rooms to preview");
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");
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));
970 if (room ==
nullptr) {
972 return absl::FailedPreconditionError(absl::StrFormat(
973 "Collision preview room 0x%03X cannot be loaded without a ROM",
976 auto& materialized = (*rooms_)[room_id];
979 room = &materialized;
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 "
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));
996 preview.push_back(std::move(generated));
1001 return absl::OkStatus();
1059 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1060 tr(
"Open a project to edit minecart tracks."));
1065 if (!binding_status.ok()) {
1070 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1071 tr(
"Open a project to edit minecart tracks."));
1092 if (ImGui::CollapsingHeader(
"Cart starts")) {
1094 if (ImGui::Checkbox(
"Edit starts on room canvas", &
canvas_editing)) {
1098 "Select a cart marker, then drag it onto a stop. "
1099 "Escape cancels. Arrow keys nudge one sprite cell.");
1103 ImGui::Text(
"Cart %d - room %03X%s", selected.id, selected.room_id,
1105 ImGui::TextDisabled(
1106 "Moving a start also moves its matching placed cart.");
1108 for (
const auto& track :
tracks_) {
1111 if (ImGui::Selectable(
1112 absl::StrFormat(
"Cart %d##cart_start_%d", track.id, track.id)
1124 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f),
1131 if (!ImGui::CollapsingHeader(
"Source and audit")) {
1134 if (!ImGui::BeginTabBar(
"##MinecartTasks")) {
1138 if (ImGui::BeginTabItem(tr(
"Routes"))) {
1139 ImGui::TextDisabled(
1140 tr(
"Edit route start slots and publish the manifest-owned ASM "
1142#if defined(__EMSCRIPTEN__)
1143 ImGui::TextDisabled(
1144 tr(
"Source publishing is unavailable in browser builds; drafts are "
1148 const bool can_publish =
1149 has_unpublished_changes && kSourcePublishingAvailable;
1151 ImGui::BeginDisabled();
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());
1164 ImGui::EndDisabled();
1167 if (!has_unpublished_changes) {
1168 ImGui::BeginDisabled();
1173 : std::string(status.message());
1176 if (!has_unpublished_changes) {
1177 ImGui::EndDisabled();
1183 : std::string(status.message());
1187 const bool can_save_project =
1189 if (!can_save_project) {
1190 ImGui::BeginDisabled();
1196 ?
"Project saved; minecart track drafts remain "
1202 absl::StrFormat(
"Project save failed: %s", status.message());
1206 if (!can_save_project) {
1207 ImGui::EndDisabled();
1212 const bool advanced = ImGui::CollapsingHeader(
"Advanced: raw start table");
1215 "World pixels use the room grid origin; there is no added $1000 "
1217 "Raw changes do not relocate placed carts. Use the canvas for paired "
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,
1226 ImGui::TableSetupColumn(
"World X", ImGuiTableColumnFlags_WidthFixed,
1228 ImGui::TableSetupColumn(
"World Y", ImGuiTableColumnFlags_WidthFixed,
1230 ImGui::TableSetupColumn(
"Pick", ImGuiTableColumnFlags_WidthFixed, 50.0f);
1231 ImGui::TableSetupColumn(
"Go", ImGuiTableColumnFlags_WidthFixed, 40.0f);
1232 ImGui::TableSetupColumn(
"Status", ImGuiTableColumnFlags_WidthFixed,
1234 ImGui::TableHeadersRow();
1237 ImGui::TableNextRow();
1240 const bool used_in_rooms =
1244 const bool missing_start = used_in_rooms && is_default;
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));
1256 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1257 IM_COL32(80, 80, 0, 100));
1260 ImGui::TableNextColumn();
1261 ImGui::Text(
"%d", track.id);
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,
1269 next.room_id = room_id;
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,
1281 next.start_x = start_x;
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,
1293 next.start_y = start_y;
1300 ImGui::TableNextColumn();
1301 ImGui::PushID(track.id);
1302 bool is_picking_this =
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));
1310 if (is_picking_this) {
1317 if (ImGui::IsItemHovered()) {
1318 ImGui::SetTooltip(is_picking_this ?
"Cancel picking"
1319 :
"Pick coordinates from canvas");
1324 ImGui::TableNextColumn();
1325 ImGui::PushID(track.id + 1000);
1331 if (ImGui::IsItemHovered()) {
1332 ImGui::SetTooltip(tr(
"Navigate to room $%04X"), track.room_id);
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),
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"));
1360 ImGui::Text(tr(
"No route reference detected"));
1366 ImGui::Text(tr(
"Rooms:"));
1367 for (
int room_id : rooms_it->second) {
1368 ImGui::BulletText(tr(
"0x%03X"), room_id);
1371 ImGui::EndTooltip();
1379 int default_count = 0;
1381 int missing_start_count = 0;
1382 for (
const auto& track :
tracks_) {
1384 bool used_in_rooms =
1391 if (used_in_rooms) {
1394 if (used_in_rooms && is_default) {
1395 missing_start_count++;
1400 ImGui::Text(tr(
"%s route slots: used %d/%d, default %d, missing starts %d"),
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();
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."));
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) {
1425 ImGui::TextDisabled(tr(
"%s audit: %d room(s) need collision."),
1427 rooms_needing_collision);
1433 ? absl::StrFormat(
"Preview ready for %d rooms.",
1435 : std::string(status.message());
1438 ImGui::TextDisabled(
1439 tr(
"Scans all %d rooms. The list below otherwise reflects loaded "
1443 int preview_tiles = 0;
1445 preview_tiles += result.tiles_generated;
1447 ImGui::Text(
ICON_MD_PREVIEW " Preview: %zu rooms, %d collision tiles",
1450 ImGui::BeginDisabled();
1453 " Apply Preview to %d Rooms",
1456 ImGui::OpenPopup(
"Confirm Minecart Collision Apply");
1459 ImGui::EndDisabled();
1468 if (ImGui::BeginPopupModal(
"Confirm Minecart Collision Apply",
nullptr,
1469 ImGuiWindowFlags_AlwaysAutoResize)) {
1470 ImGui::Text(tr(
"Apply generated collision to %zu rooms?"),
1472 ImGui::TextDisabled(tr(
"%d collision tiles will be added."),
1474 ImGui::TextDisabled(
1475 tr(
"This changes dungeon room models as one undoable edit. ROM "
1476 "bytes change only when you save."));
1483 "Applied collision to %d rooms. Save the ROM to "
1486 : std::string(status.message());
1488 ImGui::CloseCurrentPopup();
1492 ImGui::CloseCurrentPopup();
1498 if (protected_rooms > 0) {
1499 ImGui::TextDisabled(
1500 tr(
"%d room(s) with existing custom collision are protected and "
1505 ImGui::BeginChild(
"##TrackAuditRooms", ImVec2(0, 160),
true);
1506 std::vector<int> audited_room_ids;
1508 for (
const auto& [room_id, audit] :
room_audit_) {
1509 audited_room_ids.push_back(room_id);
1511 std::sort(audited_room_ids.begin(), audited_room_ids.end());
1512 for (
int room_id : audited_room_ids) {
1516 if (!audit.has_track_collision && audit.has_any_custom_collision) {
1518 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1520 " Room 0x%03X (existing custom collision; protected)",
1522 }
else if (!audit.has_track_collision) {
1523 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.1f, 1.0f),
1526 }
else if (!audit.has_minecart_on_stop) {
1528 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1531 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1536 ImGui::PushID(room_id);
1542 if (ImGui::IsItemHovered()) {
1543 ImGui::SetTooltip(tr(
"Navigate to room 0x%03X"), room_id);
1547 if (
rooms_ && !audit.track_subtypes.empty() &&
1548 !audit.has_any_custom_collision) {
1550 if (ImGui::SmallButton(
1556 ? absl::StrFormat(
"Preview ready for room 0x%03X.", room_id)
1557 : std::string(status.message());
1560 if (ImGui::IsItemHovered()) {
1562 tr(
"Preview generated collision without changing the room"));
1570 ImGui::EndTabItem();
1577 ImGui::TextColored(
show_success_ ? ImVec4(0, 1, 0, 1) : ImVec4(1, 0, 0, 1),
1641 return absl::FailedPreconditionError(
"Minecart tracks are not loaded");
1644 return absl::FailedPreconditionError(
1645 "No unpublished minecart track drafts to publish");
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 "
1656 std::filesystem::path source_path;
1659 return absl::FailedPreconditionError(
1660 "Minecart source path changed after the tracks were loaded; drafts "
1664 std::unique_ptr<core::SourceArtifactPublicationLock> publication_lock;
1667 {source_path}, kMinecartSourcePublisherLabels));
1672 std::filesystem::path locked_source_path;
1675 return absl::FailedPreconditionError(
1676 "Minecart source path changed while acquiring its publication lock; "
1677 "drafts were kept");
1680 std::string source_before;
1682 const std::string source_sha256_before =
1686 return absl::AbortedError(absl::StrFormat(
1687 "Minecart source SHA-256 CAS failed: expected %s, got %s; drafts were "
1692 std::string 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()));
1700 if (published_document_or->tracks() !=
tracks_) {
1701 return absl::DataLossError(
1702 "Rendered minecart source did not reproduce the draft tracks");
1704 const std::string source_sha256_after =
1706 const std::vector<MinecartTrack> draft_tracks =
tracks_;
1708 std::vector<core::SourceArtifactUpdate> updates;
1710 .
target = locked_source_path,
1711 .before = source_before,
1712 .after = source_after,
1716 [&]() -> absl::Status {
1717 std::string reopened_source;
1719 if (reopened_source != source_after ||
1721 source_sha256_after) {
1722 return absl::DataLossError(
1723 "Published minecart source failed exact SHA-256 readback");
1725 auto reopened_document_or =
1727 if (!reopened_document_or.ok()) {
1728 return absl::DataLossError(absl::StrFormat(
1729 "Published minecart source failed strict readback validation: "
1731 reopened_document_or.status().message()));
1733 if (reopened_document_or->tracks() != draft_tracks) {
1734 return absl::DataLossError(
1735 "Published minecart source track readback did not match the "
1738 return absl::OkStatus();
1741 tracks_ = published_document_or->tracks();
1749 return absl::OkStatus();