252 return absl::FailedPreconditionError(
253 "Project descriptor moved; reload minecart tracks to rebind the "
257 return absl::FailedPreconditionError(
258 "An open project descriptor is required for minecart tracks");
261 return absl::FailedPreconditionError(
262 "A loaded hack manifest is required for minecart tracks");
265 if (!source.has_value()) {
266 return absl::FailedPreconditionError(
267 "Hack manifest does not define minecart_tracks.source");
272 if (descriptor_path.is_relative()) {
273 descriptor_path = std::filesystem::absolute(descriptor_path, ec);
275 return absl::InvalidArgumentError(absl::StrFormat(
276 "Could not resolve project descriptor path: %s", ec.message()));
280 const std::filesystem::path project_root =
281 std::filesystem::weakly_canonical(descriptor_path.parent_path(), ec);
282 if (ec || project_root.empty() ||
283 !std::filesystem::is_directory(project_root, ec) || ec) {
284 return absl::InvalidArgumentError(
285 "Project descriptor parent is not an existing directory");
288 const std::filesystem::path candidate =
289 (project_root / source->path).lexically_normal();
290 const std::filesystem::file_status candidate_status =
291 std::filesystem::symlink_status(candidate, ec);
293 return absl::NotFoundError(
294 absl::StrFormat(
"Minecart source not found: %s", candidate.string()));
296 if (std::filesystem::is_symlink(candidate_status)) {
297 return absl::PermissionDeniedError(
298 "Minecart source may not be a symbolic link");
300 const std::filesystem::path resolved =
301 std::filesystem::canonical(candidate, ec);
303 return absl::NotFoundError(
304 absl::StrFormat(
"Minecart source not found: %s", candidate.string()));
306 if (!IsStrictDescendant(resolved, project_root)) {
307 return absl::PermissionDeniedError(
308 "Minecart source resolves outside the project root");
310 if (!std::filesystem::is_regular_file(resolved, ec) || ec) {
311 return absl::InvalidArgumentError(
312 "Minecart source must be an existing regular file");
749 std::array<bool, 256> track_tiles{};
750 std::array<bool, 256> stop_tiles{};
751 std::array<bool, 256> switch_tiles{};
752 auto apply_list = [](std::array<bool, 256>& dest,
753 const std::vector<uint16_t>& values) {
755 for (uint16_t value : values) {
756 if (value < dest.size()) {
765 std::vector<uint16_t> default_track_tiles;
766 for (uint16_t tile = 0xB0; tile <= 0xBE; ++tile) {
767 default_track_tiles.push_back(tile);
769 apply_list(track_tiles, default_track_tiles);
775 apply_list(stop_tiles, {0xB7, 0xB8, 0xB9, 0xBA});
781 apply_list(switch_tiles, {0xD0, 0xD1, 0xD2, 0xD3});
784 std::vector<uint16_t> track_object_ids = {0x31};
785 std::vector<uint16_t> minecart_sprite_ids = {0xA3};
795 std::unordered_map<int, bool> track_object_id_map;
796 for (uint16_t
id : track_object_ids) {
797 track_object_id_map[
static_cast<int>(id)] =
true;
799 std::unordered_map<int, bool> minecart_sprite_id_map;
800 for (uint16_t
id : minecart_sprite_ids) {
801 minecart_sprite_id_map[
static_cast<int>(id)] =
true;
804 for (
int room_id = 0; room_id < static_cast<int>(
rooms_->
size()); ++room_id) {
805 auto& room = (*rooms_)[room_id];
808 if (room.GetTileObjects().empty()) {
811 if (room.GetSprites().empty()) {
815 std::array<bool, kTrackSlotCount> seen_subtype{};
817 for (
const auto& obj : room.GetTileObjects()) {
818 if (!track_object_id_map[
static_cast<int>(obj.id_)]) {
821 int subtype = obj.size_ & 0x1F;
822 if (subtype >= 0 && subtype < kTrackSlotCount) {
823 if (!seen_subtype[
static_cast<size_t>(subtype)]) {
824 seen_subtype[
static_cast<size_t>(subtype)] =
true;
832 std::unordered_map<int, bool> stop_positions;
834 if (map_or.ok() && map_or.value().has_data) {
835 const auto& map = map_or.value().tiles;
836 for (
int y = 0; y < 64; ++y) {
837 for (
int x = 0; x < 64; ++x) {
838 uint8_t tile = map[
static_cast<size_t>(y * 64 + x)];
839 if (track_tiles[tile] || stop_tiles[tile] || switch_tiles[tile]) {
842 if (stop_tiles[tile]) {
844 stop_positions[y * 64 + x] =
true;
851 for (
const auto& sprite : room.GetSprites()) {
852 if (!minecart_sprite_id_map[
static_cast<int>(sprite.id())]) {
856 int tile_x = sprite.x() * 2;
857 int tile_y = sprite.y() * 2;
858 if (tile_x >= 0 && tile_x < 64 && tile_y >= 0 && tile_y < 64) {
859 int idx = tile_y * 64 + tile_x;
860 if (stop_positions[idx]) {
878 ImGui::TextColored(ImVec4(1, 0, 0, 1),
879 tr(
"Open a project to edit minecart tracks."));
884 if (!binding_status.ok()) {
889 ImGui::TextColored(ImVec4(1, 0, 0, 1),
890 tr(
"Open a project to edit minecart tracks."));
906 ImGui::Text(tr(
"Minecart Track Editor"));
908 tr(
"Publish Tracks changes the manifest-owned ASM source only; it does "
909 "not update the open ROM."));
911 tr(
"After publishing, save pending dungeon/ROM edits to the development "
912 "ROM, rebuild the patched ROM, then reopen/reload the patched ROM in "
913 "Yaze before testing."));
914#if defined(__EMSCRIPTEN__)
916 tr(
"Source publishing is unavailable in browser builds; drafts are "
920 const bool can_publish =
921 has_unpublished_changes && kSourcePublishingAvailable;
923 ImGui::BeginDisabled();
929 ?
"Minecart ASM source published only. Save pending dungeon/ROM "
930 "edits to the development ROM, rebuild the patched ROM, then "
931 "reopen/reload the patched ROM in Yaze before testing."
932 : std::string(status.message());
936 ImGui::EndDisabled();
939 if (!has_unpublished_changes) {
940 ImGui::BeginDisabled();
945 : std::string(status.message());
948 if (!has_unpublished_changes) {
949 ImGui::EndDisabled();
955 : std::string(status.message());
959 const bool can_save_project =
961 if (!can_save_project) {
962 ImGui::BeginDisabled();
968 ?
"Project saved; minecart track drafts remain "
974 absl::StrFormat(
"Project save failed: %s", status.message());
978 if (!can_save_project) {
979 ImGui::EndDisabled();
989 ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f),
996 ImGui::TextColored(
show_success_ ? ImVec4(0, 1, 0, 1) : ImVec4(1, 0, 0, 1),
1004 ImGui::TextDisabled(tr(
1005 "Camera coordinates use $1XXX format (base $1000 + room offset + local "
1007 ImGui::TextDisabled(tr(
1008 "Hover over dungeon canvas to see coordinates, or click 'Pick' button."));
1011 if (ImGui::BeginTable(
"TracksTable", 7,
1012 ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
1013 ImGuiTableFlags_Resizable)) {
1014 ImGui::TableSetupColumn(
"ID", ImGuiTableColumnFlags_WidthFixed, 30.0f);
1015 ImGui::TableSetupColumn(
"Room ID", ImGuiTableColumnFlags_WidthFixed, 80.0f);
1016 ImGui::TableSetupColumn(
"Camera X", ImGuiTableColumnFlags_WidthFixed,
1018 ImGui::TableSetupColumn(
"Camera Y", ImGuiTableColumnFlags_WidthFixed,
1020 ImGui::TableSetupColumn(
"Pick", ImGuiTableColumnFlags_WidthFixed, 50.0f);
1021 ImGui::TableSetupColumn(
"Go", ImGuiTableColumnFlags_WidthFixed, 40.0f);
1022 ImGui::TableSetupColumn(
"Status", ImGuiTableColumnFlags_WidthFixed, 60.0f);
1023 ImGui::TableHeadersRow();
1026 ImGui::TableNextRow();
1029 const bool used_in_rooms =
1033 const bool missing_start = used_in_rooms && is_default;
1035 if (missing_start) {
1036 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1037 IM_COL32(120, 40, 40, 120));
1038 }
else if (is_default) {
1039 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1040 IM_COL32(60, 60, 60, 80));
1045 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
1046 IM_COL32(80, 80, 0, 100));
1049 ImGui::TableNextColumn();
1050 ImGui::Text(
"%d", track.id);
1052 ImGui::TableNextColumn();
1053 uint16_t room_id =
static_cast<uint16_t
>(track.room_id);
1055 absl::StrFormat(
"##Room%d", track.id).c_str(), &room_id, 60.0f)) {
1056 track.room_id = room_id;
1060 ImGui::TableNextColumn();
1061 uint16_t start_x =
static_cast<uint16_t
>(track.start_x);
1063 absl::StrFormat(
"##StartX%d", track.id).c_str(), &start_x,
1065 track.start_x = start_x;
1069 ImGui::TableNextColumn();
1070 uint16_t start_y =
static_cast<uint16_t
>(track.start_y);
1072 absl::StrFormat(
"##StartY%d", track.id).c_str(), &start_y,
1074 track.start_y = start_y;
1079 ImGui::TableNextColumn();
1080 ImGui::PushID(track.id);
1083 std::optional<gui::StyleColorGuard> pick_guard;
1084 if (is_picking_this) {
1085 pick_guard.emplace(ImGuiCol_Button, ImVec4(0.8f, 0.6f, 0.0f, 1.0f));
1088 if (is_picking_this) {
1095 if (ImGui::IsItemHovered()) {
1096 ImGui::SetTooltip(is_picking_this ?
"Cancel picking"
1097 :
"Pick coordinates from canvas");
1102 ImGui::TableNextColumn();
1103 ImGui::PushID(track.id + 1000);
1109 if (ImGui::IsItemHovered()) {
1110 ImGui::SetTooltip(tr(
"Navigate to room $%04X"), track.room_id);
1115 ImGui::TableNextColumn();
1116 if (missing_start) {
1117 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1119 }
else if (is_default) {
1120 ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f),
ICON_MD_INFO);
1121 }
else if (used_in_rooms) {
1122 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1128 if (ImGui::IsItemHovered()) {
1129 ImGui::BeginTooltip();
1130 if (missing_start) {
1131 ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1132 tr(
"Used in rooms but still default"));
1133 }
else if (is_default) {
1134 ImGui::Text(tr(
"Default filler slot"));
1135 }
else if (used_in_rooms) {
1136 ImGui::Text(tr(
"Used in rooms"));
1138 ImGui::Text(tr(
"No usage detected"));
1144 ImGui::Text(tr(
"Rooms:"));
1145 for (
int room_id : rooms_it->second) {
1146 ImGui::BulletText(tr(
"0x%03X"), room_id);
1149 ImGui::EndTooltip();
1157 int default_count = 0;
1159 int missing_start_count = 0;
1160 for (
const auto& track :
tracks_) {
1162 bool used_in_rooms =
1169 if (used_in_rooms) {
1172 if (used_in_rooms && is_default) {
1173 missing_start_count++;
1178 ImGui::Text(tr(
"Usage Summary: used %d/%d, default %d, missing starts %d"),
1179 used_count, kTrackSlotCount, default_count, missing_start_count);
1183 ImGui::Text(tr(
"Rooms with track objects:"));
1189 int rooms_needing_collision = 0;
1191 if (!audit.track_subtypes.empty() && !audit.has_track_collision) {
1192 rooms_needing_collision++;
1196 if (rooms_needing_collision > 0) {
1198 " Generate All (%d rooms)",
1199 rooms_needing_collision)
1201 int generated_rooms = 0;
1202 int total_tiles = 0;
1203 bool had_error =
false;
1206 if (audit.track_subtypes.empty() || audit.has_track_collision) {
1210 auto& target_room = (*rooms_)[rid];
1214 if (!gen_result.ok()) {
1216 absl::StrFormat(
"Generate failed for room 0x%03X: %s", rid,
1217 gen_result.status().message());
1224 rom_, rid, gen_result->collision_map);
1225 if (!write_status.ok()) {
1227 absl::StrFormat(
"Write failed for room 0x%03X: %s", rid,
1228 write_status.message());
1235 total_tiles += gen_result->tiles_generated;
1240 "Generated collision for %d rooms (%d tiles total)",
1241 generated_rooms, total_tiles);
1246 if (ImGui::IsItemHovered()) {
1248 tr(
"Generate collision for all %d rooms with rail objects "
1249 "but no collision data"),
1250 rooms_needing_collision);
1255 ImGui::BeginChild(
"##TrackAuditRooms", ImVec2(0, 160),
true);
1256 for (
const auto& [room_id, audit] :
room_audit_) {
1257 if (audit.track_subtypes.empty() && !audit.has_track_collision) {
1262 if (!audit.has_track_collision) {
1263 ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.1f, 1.0f),
1266 }
else if (!audit.has_minecart_on_stop) {
1268 ImVec4(1.0f, 0.6f, 0.1f, 1.0f),
1271 ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f),
1276 ImGui::PushID(room_id);
1282 if (ImGui::IsItemHovered()) {
1283 ImGui::SetTooltip(tr(
"Navigate to room 0x%03X"), room_id);
1287 if (
rom_ &&
rooms_ && !audit.has_track_collision) {
1289 if (ImGui::SmallButton(
1292 auto& target_room = (*rooms_)[room_id];
1295 if (gen_result.ok()) {
1297 rom_, room_id, gen_result->collision_map);
1298 if (write_status.ok()) {
1300 "Room 0x%03X: Generated %d tiles (%d stops, %d corners)",
1301 room_id, gen_result->tiles_generated, gen_result->stop_count,
1302 gen_result->corner_count);
1307 absl::StrFormat(
"Write failed: %s", write_status.message());
1312 gen_result.status().message());
1316 if (ImGui::IsItemHovered()) {
1317 ImGui::SetTooltip(tr(
1318 "Auto-generate collision tiles from rail objects in this room"));
1376 return absl::FailedPreconditionError(
"Minecart tracks are not loaded");
1379 return absl::FailedPreconditionError(
1380 "No unpublished minecart track drafts to publish");
1382#if defined(__EMSCRIPTEN__)
1383 return absl::FailedPreconditionError(
1384 "Minecart source publishing is unavailable in browser builds because "
1385 "durable atomic filesystem publication cannot be guaranteed; drafts "
1391 std::filesystem::path source_path;
1394 return absl::FailedPreconditionError(
1395 "Minecart source path changed after the tracks were loaded; drafts "
1399 std::unique_ptr<core::SourceArtifactPublicationLock> publication_lock;
1402 {source_path}, kMinecartSourcePublisherLabels));
1407 std::filesystem::path locked_source_path;
1410 return absl::FailedPreconditionError(
1411 "Minecart source path changed while acquiring its publication lock; "
1412 "drafts were kept");
1415 std::string source_before;
1417 const std::string source_sha256_before =
1421 return absl::AbortedError(absl::StrFormat(
1422 "Minecart source SHA-256 CAS failed: expected %s, got %s; drafts were "
1427 std::string source_after;
1430 if (!published_document_or.ok()) {
1431 return absl::DataLossError(
1432 absl::StrFormat(
"Rendered minecart source failed strict validation: %s",
1433 published_document_or.status().message()));
1435 if (published_document_or->tracks() !=
tracks_) {
1436 return absl::DataLossError(
1437 "Rendered minecart source did not reproduce the draft tracks");
1439 const std::string source_sha256_after =
1441 const std::vector<MinecartTrack> draft_tracks =
tracks_;
1443 std::vector<core::SourceArtifactUpdate> updates;
1445 .
target = locked_source_path,
1446 .before = source_before,
1447 .after = source_after,
1451 [&]() -> absl::Status {
1452 std::string reopened_source;
1454 if (reopened_source != source_after ||
1456 source_sha256_after) {
1457 return absl::DataLossError(
1458 "Published minecart source failed exact SHA-256 readback");
1460 auto reopened_document_or =
1462 if (!reopened_document_or.ok()) {
1463 return absl::DataLossError(absl::StrFormat(
1464 "Published minecart source failed strict readback validation: "
1466 reopened_document_or.status().message()));
1468 if (reopened_document_or->tracks() != draft_tracks) {
1469 return absl::DataLossError(
1470 "Published minecart source track readback did not match the "
1473 return absl::OkStatus();
1476 tracks_ = published_document_or->tracks();
1483 return absl::OkStatus();