265 return absl::FailedPreconditionError(
266 "Project descriptor moved; reload minecart tracks to rebind the "
270 return absl::FailedPreconditionError(
271 "An open project descriptor is required for minecart tracks");
274 return absl::FailedPreconditionError(
275 "A loaded hack manifest is required for minecart tracks");
278 if (!source.has_value()) {
279 return absl::FailedPreconditionError(
280 "Hack manifest does not define minecart_tracks.source");
285 if (descriptor_path.is_relative()) {
286 descriptor_path = std::filesystem::absolute(descriptor_path, ec);
288 return absl::InvalidArgumentError(absl::StrFormat(
289 "Could not resolve project descriptor path: %s", ec.message()));
293 const std::filesystem::path project_root =
294 std::filesystem::weakly_canonical(descriptor_path.parent_path(), ec);
295 if (ec || project_root.empty() ||
296 !std::filesystem::is_directory(project_root, ec) || ec) {
297 return absl::InvalidArgumentError(
298 "Project descriptor parent is not an existing directory");
301 const std::filesystem::path candidate =
302 (project_root / source->path).lexically_normal();
303 const std::filesystem::file_status candidate_status =
304 std::filesystem::symlink_status(candidate, ec);
306 return absl::NotFoundError(
307 absl::StrFormat(
"Minecart source not found: %s", candidate.string()));
309 if (std::filesystem::is_symlink(candidate_status)) {
310 return absl::PermissionDeniedError(
311 "Minecart source may not be a symbolic link");
313 const std::filesystem::path resolved =
314 std::filesystem::canonical(candidate, ec);
316 return absl::NotFoundError(
317 absl::StrFormat(
"Minecart source not found: %s", candidate.string()));
319 if (!IsStrictDescendant(resolved, project_root)) {
320 return absl::PermissionDeniedError(
321 "Minecart source resolves outside the project root");
323 if (!std::filesystem::is_regular_file(resolved, ec) || ec) {
324 return absl::InvalidArgumentError(
325 "Minecart source must be an existing regular file");
780 std::array<bool, 256> track_tiles{};
781 std::array<bool, 256> stop_tiles{};
782 std::array<bool, 256> switch_tiles{};
783 auto apply_list = [](std::array<bool, 256>& dest,
784 const std::vector<uint16_t>& values) {
786 for (uint16_t value : values) {
787 if (value < dest.size()) {
796 std::vector<uint16_t> default_track_tiles;
797 for (uint16_t tile = 0xB0; tile <= 0xBE; ++tile) {
798 default_track_tiles.push_back(tile);
800 apply_list(track_tiles, default_track_tiles);
806 apply_list(stop_tiles, {0xB7, 0xB8, 0xB9, 0xBA});
812 apply_list(switch_tiles, {0xD0, 0xD1, 0xD2, 0xD3});
815 std::vector<uint16_t> track_object_ids = {0x31};
816 std::vector<uint16_t> minecart_sprite_ids = {0xA3};
826 std::unordered_map<int, bool> track_object_id_map;
827 for (uint16_t
id : track_object_ids) {
828 track_object_id_map[
static_cast<int>(id)] =
true;
830 std::unordered_map<int, bool> minecart_sprite_id_map;
831 for (uint16_t
id : minecart_sprite_ids) {
832 minecart_sprite_id_map[
static_cast<int>(id)] =
true;
835 auto audit_room = [&](
int room_id,
zelda3::Room& room) {
838 room.EnsureObjectsLoaded();
839 room.EnsureSpritesLoaded();
841 std::array<bool, kTrackSlotCount> seen_subtype{};
843 for (
const auto& obj : room.GetTileObjects()) {
844 if (!track_object_id_map[
static_cast<int>(obj.id_)]) {
847 int subtype = obj.size_ & 0x1F;
849 subtype >= 0 && subtype < kTrackSlotCount) {
850 if (!seen_subtype[
static_cast<size_t>(subtype)]) {
851 seen_subtype[
static_cast<size_t>(subtype)] =
true;
857 std::unordered_map<int, bool> stop_positions;
858 const auto& collision = room.custom_collision();
861 const auto& map = collision.tiles;
862 for (
int y = 0; y < 64; ++y) {
863 for (
int x = 0; x < 64; ++x) {
864 uint8_t tile = map[
static_cast<size_t>(y * 64 + x)];
865 if (track_tiles[tile] || stop_tiles[tile] || switch_tiles[tile]) {
868 if (stop_tiles[tile]) {
870 stop_positions[y * 64 + x] =
true;
876 std::array<bool, kTrackSlotCount> seen_route_slot{};
877 for (
const auto& sprite : room.GetSprites()) {
878 if (!minecart_sprite_id_map[
static_cast<int>(sprite.id())]) {
882 const int route_slot = sprite.subtype();
883 if (route_slot >= 0 && route_slot < kTrackSlotCount &&
884 !seen_route_slot[
static_cast<size_t>(route_slot)]) {
885 seen_route_slot[
static_cast<size_t>(route_slot)] =
true;
890 int tile_x = sprite.x() * 2;
891 int tile_y = sprite.y() * 2;
892 if (tile_x >= 0 && tile_x < 64 && tile_y >= 0 && tile_y < 64) {
893 int idx = tile_y * 64 + tile_x;
894 if (stop_positions[idx]) {
909 for (
int room_id = 0; room_id < static_cast<int>(
rooms_->
size()); ++room_id) {
911 audit_room(room_id, *room);
914 if (!include_unmaterialized) {
921 audit_room(room_id, room);
944 const std::vector<int>& room_ids) {
946 return absl::FailedPreconditionError(
"Dungeon rooms are unavailable");
948 if (room_ids.empty()) {
949 return absl::InvalidArgumentError(
"No eligible rooms to preview");
953 std::vector<int> sorted_room_ids = room_ids;
954 std::sort(sorted_room_ids.begin(), sorted_room_ids.end());
955 if (std::adjacent_find(sorted_room_ids.begin(), sorted_room_ids.end()) !=
956 sorted_room_ids.end()) {
957 return absl::InvalidArgumentError(
958 "Collision preview contains a duplicate room ID");
961 std::vector<zelda3::TrackCollisionResult> preview;
962 preview.reserve(sorted_room_ids.size());
963 for (
int room_id : sorted_room_ids) {
964 if (room_id < 0 || room_id >=
static_cast<int>(
rooms_->
size())) {
965 return absl::OutOfRangeError(absl::StrFormat(
966 "Collision preview room 0x%03X is out of range", room_id));
969 if (room ==
nullptr) {
971 return absl::FailedPreconditionError(absl::StrFormat(
972 "Collision preview room 0x%03X cannot be loaded without a ROM",
975 auto& materialized = (*rooms_)[room_id];
978 room = &materialized;
980 room->EnsureObjectsLoaded();
981 if (HasAnyCustomCollision(room->custom_collision())) {
982 return absl::FailedPreconditionError(absl::StrFormat(
983 "Room 0x%03X already has custom collision; generation will not "
990 generated.room_id = room_id;
991 if (!generated.collision_map.has_data || generated.tiles_generated <= 0) {
992 return absl::FailedPreconditionError(absl::StrFormat(
993 "Room 0x%03X has no supported minecart track pieces", room_id));
995 preview.push_back(std::move(generated));
1000 return absl::OkStatus();
1057 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1058 tr(
"Open a project to edit minecart tracks."));
1063 if (!binding_status.ok()) {
1068 ImGui::TextColored(ImVec4(1, 0, 0, 1),
1069 tr(
"Open a project to edit minecart tracks."));
1087 ImGui::TextUnformatted(
"Cart starts");
1089 if (ImGui::Checkbox(
"Edit starts on room canvas", &
canvas_editing)) {
1093 "Select a cart marker, then drag it onto a stop. "
1094 "Escape cancels. Arrow keys nudge one sprite cell.");
1098 ImGui::Text(
"Cart %d - room %03X%s", selected.id, selected.room_id,
1100 ImGui::TextDisabled(
"Moving a start also moves its matching placed cart.");
1102 for (
const auto& track :
tracks_) {
1105 if (ImGui::Selectable(
1106 absl::StrFormat(
"Cart %d##cart_start_%d", track.id, track.id)
1118 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f),
1122 if (!ImGui::BeginTabBar(
"##MinecartTasks")) {
1126 if (ImGui::BeginTabItem(tr(
"Routes"))) {
1127 ImGui::TextDisabled(
1128 tr(
"Edit route start slots and publish the manifest-owned ASM "
1130#if defined(__EMSCRIPTEN__)
1131 ImGui::TextDisabled(
1132 tr(
"Source publishing is unavailable in browser builds; drafts are "
1136 const bool can_publish =
1137 has_unpublished_changes && kSourcePublishingAvailable;
1139 ImGui::BeginDisabled();
1145 ?
"Minecart ASM source published only. Save pending dungeon/ROM "
1146 "edits to the development ROM, rebuild the patched ROM, then "
1147 "reopen/reload the patched ROM in Yaze before testing."
1148 : std::string(status.message());
1152 ImGui::EndDisabled();
1155 if (!has_unpublished_changes) {
1156 ImGui::BeginDisabled();
1161 : std::string(status.message());
1164 if (!has_unpublished_changes) {
1165 ImGui::EndDisabled();
1171 : std::string(status.message());
1175 const bool can_save_project =
1177 if (!can_save_project) {
1178 ImGui::BeginDisabled();
1184 ?
"Project saved; minecart track drafts remain "
1190 absl::StrFormat(
"Project save failed: %s", status.message());
1194 if (!can_save_project) {
1195 ImGui::EndDisabled();
1200 const bool advanced = ImGui::CollapsingHeader(
"Advanced: raw start table");
1203 "World pixels use the room grid origin; there is no added $1000 "
1205 "Raw changes do not relocate placed carts. Use the canvas for paired "
1208 ImGui::BeginTable(
"TracksTable", 7,
1209 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
1210 ImGuiTableFlags_Resizable)) {
1211 ImGui::TableSetupColumn(
"ID", ImGuiTableColumnFlags_WidthFixed, 30.0f);
1212 ImGui::TableSetupColumn(
"Room ID", ImGuiTableColumnFlags_WidthFixed,
1214 ImGui::TableSetupColumn(
"World X", ImGuiTableColumnFlags_WidthFixed,
1216 ImGui::TableSetupColumn(
"World Y", ImGuiTableColumnFlags_WidthFixed,
1218 ImGui::TableSetupColumn(
"Pick", ImGuiTableColumnFlags_WidthFixed, 50.0f);
1219 ImGui::TableSetupColumn(
"Go", ImGuiTableColumnFlags_WidthFixed, 40.0f);
1220 ImGui::TableSetupColumn(
"Status", ImGuiTableColumnFlags_WidthFixed,
1222 ImGui::TableHeadersRow();
1225 ImGui::TableNextRow();
1228 const bool used_in_rooms =
1232 const bool missing_start = used_in_rooms && is_default;
1234 if (missing_start) {
1235 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1236 IM_COL32(120, 40, 40, 120));
1237 }
else if (is_default) {
1238 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1239 IM_COL32(60, 60, 60, 80));
1244 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1245 IM_COL32(80, 80, 0, 100));
1248 ImGui::TableNextColumn();
1249 ImGui::Text(
"%d", track.id);
1251 ImGui::TableNextColumn();
1252 uint16_t room_id =
static_cast<uint16_t
>(track.room_id);
1254 absl::StrFormat(
"##Room%d", track.id).c_str(), &room_id,
1257 next.room_id = room_id;
1263 ImGui::TableNextColumn();
1264 uint16_t start_x =
static_cast<uint16_t
>(track.start_x);
1266 absl::StrFormat(
"##StartX%d", track.id).c_str(), &start_x,
1269 next.start_x = start_x;
1275 ImGui::TableNextColumn();
1276 uint16_t start_y =
static_cast<uint16_t
>(track.start_y);
1278 absl::StrFormat(
"##StartY%d", track.id).c_str(), &start_y,
1281 next.start_y = start_y;
1288 ImGui::TableNextColumn();
1289 ImGui::PushID(track.id);
1290 bool is_picking_this =
1293 std::optional<gui::StyleColorGuard> pick_guard;
1294 if (is_picking_this) {
1295 pick_guard.emplace(ImGuiCol_Button, ImVec4(0.8f, 0.6f, 0.0f, 1.0f));
1298 if (is_picking_this) {
1305 if (ImGui::IsItemHovered()) {
1306 ImGui::SetTooltip(is_picking_this ?
"Cancel picking"
1307 :
"Pick coordinates from canvas");
1312 ImGui::TableNextColumn();
1313 ImGui::PushID(track.id + 1000);
1319 if (ImGui::IsItemHovered()) {
1320 ImGui::SetTooltip(tr(
"Navigate to room $%04X"), track.room_id);
1325 ImGui::TableNextColumn();
1326 if (missing_start) {
1327 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1329 }
else if (is_default) {
1330 ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f),
ICON_MD_INFO);
1331 }
else if (used_in_rooms) {
1332 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1338 if (ImGui::IsItemHovered()) {
1339 ImGui::BeginTooltip();
1340 if (missing_start) {
1341 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1342 tr(
"Referenced by a cart but still default"));
1343 }
else if (is_default) {
1344 ImGui::Text(tr(
"Default filler slot"));
1345 }
else if (used_in_rooms) {
1346 ImGui::Text(tr(
"Referenced by a minecart sprite"));
1348 ImGui::Text(tr(
"No route reference detected"));
1354 ImGui::Text(tr(
"Rooms:"));
1355 for (
int room_id : rooms_it->second) {
1356 ImGui::BulletText(tr(
"0x%03X"), room_id);
1359 ImGui::EndTooltip();
1367 int default_count = 0;
1369 int missing_start_count = 0;
1370 for (
const auto& track :
tracks_) {
1372 bool used_in_rooms =
1379 if (used_in_rooms) {
1382 if (used_in_rooms && is_default) {
1383 missing_start_count++;
1388 ImGui::Text(tr(
"%s route slots: used %d/%d, default %d, missing starts %d"),
1390 used_count, kTrackSlotCount, default_count,
1391 missing_start_count);
1392 ImGui::TextDisabled(
1393 tr(
"Route usage comes from minecart sprite subtypes, not visual track "
1394 "piece subtypes."));
1395 ImGui::EndTabItem();
1398 if (ImGui::BeginTabItem(tr(
"Collision"))) {
1399 ImGui::TextDisabled(
1400 tr(
"Audit room collision, preview generated changes, then apply them "
1401 "as one undoable dungeon edit."));
1403 int rooms_needing_collision = 0;
1404 int protected_rooms = 0;
1405 for (
const auto& [room_id, audit] :
room_audit_) {
1406 if (!audit.track_subtypes.empty() && !audit.has_any_custom_collision) {
1407 ++rooms_needing_collision;
1408 }
else if (!audit.track_subtypes.empty() &&
1409 audit.has_any_custom_collision) {
1413 ImGui::TextDisabled(tr(
"%s audit: %d room(s) need collision."),
1415 rooms_needing_collision);
1421 ? absl::StrFormat(
"Preview ready for %d rooms.",
1423 : std::string(status.message());
1426 ImGui::TextDisabled(
1427 tr(
"Scans all %d rooms. The list below otherwise reflects loaded "
1431 int preview_tiles = 0;
1433 preview_tiles += result.tiles_generated;
1435 ImGui::Text(
ICON_MD_PREVIEW " Preview: %zu rooms, %d collision tiles",
1438 ImGui::BeginDisabled();
1441 " Apply Preview to %d Rooms",
1444 ImGui::OpenPopup(
"Confirm Minecart Collision Apply");
1447 ImGui::EndDisabled();
1456 if (ImGui::BeginPopupModal(
"Confirm Minecart Collision Apply",
nullptr,
1457 ImGuiWindowFlags_AlwaysAutoResize)) {
1458 ImGui::Text(tr(
"Apply generated collision to %zu rooms?"),
1460 ImGui::TextDisabled(tr(
"%d collision tiles will be added."),
1462 ImGui::TextDisabled(
1463 tr(
"This changes dungeon room models as one undoable edit. ROM "
1464 "bytes change only when you save."));
1471 "Applied collision to %d rooms. Save the ROM to "
1474 : std::string(status.message());
1476 ImGui::CloseCurrentPopup();
1480 ImGui::CloseCurrentPopup();
1486 if (protected_rooms > 0) {
1487 ImGui::TextDisabled(
1488 tr(
"%d room(s) with existing custom collision are protected and "
1493 ImGui::BeginChild(
"##TrackAuditRooms", ImVec2(0, 160),
true);
1494 std::vector<int> audited_room_ids;
1496 for (
const auto& [room_id, audit] :
room_audit_) {
1497 audited_room_ids.push_back(room_id);
1499 std::sort(audited_room_ids.begin(), audited_room_ids.end());
1500 for (
int room_id : audited_room_ids) {
1504 if (!audit.has_track_collision && audit.has_any_custom_collision) {
1506 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1508 " Room 0x%03X (existing custom collision; protected)",
1510 }
else if (!audit.has_track_collision) {
1511 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.1f, 1.0f),
1514 }
else if (!audit.has_minecart_on_stop) {
1516 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1519 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1524 ImGui::PushID(room_id);
1530 if (ImGui::IsItemHovered()) {
1531 ImGui::SetTooltip(tr(
"Navigate to room 0x%03X"), room_id);
1535 if (
rooms_ && !audit.track_subtypes.empty() &&
1536 !audit.has_any_custom_collision) {
1538 if (ImGui::SmallButton(
1544 ? absl::StrFormat(
"Preview ready for room 0x%03X.", room_id)
1545 : std::string(status.message());
1548 if (ImGui::IsItemHovered()) {
1550 tr(
"Preview generated collision without changing the room"));
1558 ImGui::EndTabItem();
1565 ImGui::TextColored(
show_success_ ? ImVec4(0, 1, 0, 1) : ImVec4(1, 0, 0, 1),
1629 return absl::FailedPreconditionError(
"Minecart tracks are not loaded");
1632 return absl::FailedPreconditionError(
1633 "No unpublished minecart track drafts to publish");
1635#if defined(__EMSCRIPTEN__)
1636 return absl::FailedPreconditionError(
1637 "Minecart source publishing is unavailable in browser builds because "
1638 "durable atomic filesystem publication cannot be guaranteed; drafts "
1644 std::filesystem::path source_path;
1647 return absl::FailedPreconditionError(
1648 "Minecart source path changed after the tracks were loaded; drafts "
1652 std::unique_ptr<core::SourceArtifactPublicationLock> publication_lock;
1655 {source_path}, kMinecartSourcePublisherLabels));
1660 std::filesystem::path locked_source_path;
1663 return absl::FailedPreconditionError(
1664 "Minecart source path changed while acquiring its publication lock; "
1665 "drafts were kept");
1668 std::string source_before;
1670 const std::string source_sha256_before =
1674 return absl::AbortedError(absl::StrFormat(
1675 "Minecart source SHA-256 CAS failed: expected %s, got %s; drafts were "
1680 std::string source_after;
1683 if (!published_document_or.ok()) {
1684 return absl::DataLossError(
1685 absl::StrFormat(
"Rendered minecart source failed strict validation: %s",
1686 published_document_or.status().message()));
1688 if (published_document_or->tracks() !=
tracks_) {
1689 return absl::DataLossError(
1690 "Rendered minecart source did not reproduce the draft tracks");
1692 const std::string source_sha256_after =
1694 const std::vector<MinecartTrack> draft_tracks =
tracks_;
1696 std::vector<core::SourceArtifactUpdate> updates;
1698 .
target = locked_source_path,
1699 .before = source_before,
1700 .after = source_after,
1704 [&]() -> absl::Status {
1705 std::string reopened_source;
1707 if (reopened_source != source_after ||
1709 source_sha256_after) {
1710 return absl::DataLossError(
1711 "Published minecart source failed exact SHA-256 readback");
1713 auto reopened_document_or =
1715 if (!reopened_document_or.ok()) {
1716 return absl::DataLossError(absl::StrFormat(
1717 "Published minecart source failed strict readback validation: "
1719 reopened_document_or.status().message()));
1721 if (reopened_document_or->tracks() != draft_tracks) {
1722 return absl::DataLossError(
1723 "Published minecart source track readback did not match the "
1726 return absl::OkStatus();
1729 tracks_ = published_document_or->tracks();
1737 return absl::OkStatus();