yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_collision_commands.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <atomic>
6#include <cerrno>
7#include <chrono>
8#include <cstdint>
9#include <filesystem>
10#include <fstream>
11#include <ios>
12#include <limits>
13#include <sstream>
14#include <string>
15#include <system_error>
16#include <unordered_map>
17#include <unordered_set>
18#include <utility>
19#include <vector>
20
21#include "absl/flags/declare.h"
22#include "absl/flags/flag.h"
23#include "absl/status/status.h"
24#include "absl/strings/ascii.h"
25#include "absl/strings/str_format.h"
26#include "absl/strings/str_split.h"
27#include "absl/strings/string_view.h"
28#include "absl/types/span.h"
29#include "cli/util/hex_util.h"
30#include "nlohmann/json.hpp"
31#include "rom/rom.h"
32#include "rom/transaction.h"
33#include "util/macro.h"
37#include "zelda3/dungeon/room.h"
39
40#if defined(_WIN32)
41#ifndef NOMINMAX
42#define NOMINMAX
43#endif
44#include <windows.h>
45#else
46#include <fcntl.h>
47#include <unistd.h>
48#endif
49
50ABSL_DECLARE_FLAG(bool, sandbox);
51
52namespace yaze {
53namespace cli {
54namespace handlers {
55
57
58namespace {
59
60constexpr int kCollisionGridSize = 64;
61using json = nlohmann::json;
62
63absl::StatusOr<std::unordered_set<int>> ParseTileFilter(
64 const resources::ArgumentParser& parser) {
65 std::unordered_set<int> tiles;
66 auto tiles_opt = parser.GetString("tiles");
67 if (!tiles_opt.has_value()) {
68 return tiles;
69 }
70
71 for (absl::string_view token :
72 absl::StrSplit(tiles_opt.value(), ',', absl::SkipEmpty())) {
73 std::string t = std::string(absl::StripAsciiWhitespace(token));
74 int v = 0;
75 if (!ParseHexString(t, &v)) {
76 return absl::InvalidArgumentError(
77 absl::StrFormat("Invalid tile value in --tiles: %s", t));
78 }
79 if (v < 0 || v > 0xFF) {
80 return absl::InvalidArgumentError(
81 absl::StrFormat("Tile value out of range (0x00-0xFF): %s", t));
82 }
83 tiles.insert(v);
84 }
85
86 return tiles;
87}
88
89absl::StatusOr<int> ParseRoomIdToken(absl::string_view token) {
90 std::string trimmed = std::string(absl::StripAsciiWhitespace(token));
91 int room_id = 0;
92 if (!ParseHexString(trimmed, &room_id)) {
93 return absl::InvalidArgumentError(
94 absl::StrFormat("Invalid room ID: %s", trimmed));
95 }
96 if (room_id < 0 || room_id >= zelda3::kNumberOfRooms) {
97 return absl::OutOfRangeError(
98 absl::StrFormat("Room ID out of range: 0x%02X", room_id));
99 }
100 return room_id;
101}
102
103absl::StatusOr<std::vector<int>> ParseRoomSelection(
104 const resources::ArgumentParser& parser) {
105 std::vector<int> room_ids;
106 bool any_explicit = false;
107
108 if (auto room_opt = parser.GetString("room"); room_opt.has_value()) {
109 any_explicit = true;
110 ASSIGN_OR_RETURN(int room_id, ParseRoomIdToken(room_opt.value()));
111 room_ids.push_back(room_id);
112 }
113
114 if (auto rooms_opt = parser.GetString("rooms"); rooms_opt.has_value()) {
115 any_explicit = true;
116 for (absl::string_view token :
117 absl::StrSplit(rooms_opt.value(), ',', absl::SkipEmpty())) {
118 ASSIGN_OR_RETURN(int room_id, ParseRoomIdToken(token));
119 room_ids.push_back(room_id);
120 }
121 }
122
123 if (parser.HasFlag("all")) {
124 any_explicit = true;
125 room_ids.clear();
126 room_ids.reserve(zelda3::kNumberOfRooms);
127 for (int room_id = 0; room_id < zelda3::kNumberOfRooms; ++room_id) {
128 room_ids.push_back(room_id);
129 }
130 }
131
132 if (!any_explicit) {
133 room_ids.reserve(zelda3::kNumberOfRooms);
134 for (int room_id = 0; room_id < zelda3::kNumberOfRooms; ++room_id) {
135 room_ids.push_back(room_id);
136 }
137 }
138
139 std::sort(room_ids.begin(), room_ids.end());
140 room_ids.erase(std::unique(room_ids.begin(), room_ids.end()), room_ids.end());
141
142 if (room_ids.empty()) {
143 return absl::InvalidArgumentError(
144 "No rooms selected (use --room, --rooms, or --all)");
145 }
146 return room_ids;
147}
148
149absl::StatusOr<std::string> ReadTextFile(const std::string& path) {
150 std::ifstream in(path, std::ios::in | std::ios::binary);
151 if (!in.is_open()) {
152 return absl::NotFoundError(
153 absl::StrFormat("Cannot open file for reading: %s", path));
154 }
155 std::stringstream ss;
156 ss << in.rdbuf();
157 if (!in.good() && !in.eof()) {
158 return absl::InternalError(
159 absl::StrFormat("Failed while reading file: %s", path));
160 }
161 return ss.str();
162}
163
164absl::StatusOr<std::filesystem::path> ResolveStableArtifactPath(
165 const std::filesystem::path& path) {
166 std::error_code absolute_ec;
167 std::filesystem::path absolute = std::filesystem::absolute(path, absolute_ec);
168 if (absolute_ec) {
169 return absl::InvalidArgumentError(absl::StrFormat(
170 "Cannot normalize path %s: %s", path.string(), absolute_ec.message()));
171 }
172 absolute = absolute.lexically_normal();
173
174 // Resolve every existing component once. Publication then uses this stable
175 // path instead of following a caller-supplied parent symlink a second time.
176 std::error_code canonical_ec;
177 const std::filesystem::path canonical =
178 std::filesystem::weakly_canonical(absolute, canonical_ec);
179 if (canonical_ec) {
180 return absl::FailedPreconditionError(
181 absl::StrFormat("Cannot safely resolve path %s: %s", absolute.string(),
182 canonical_ec.message()));
183 }
184 const std::filesystem::path resolved = canonical.lexically_normal();
185 const std::filesystem::path parent = resolved.parent_path();
186 std::error_code parent_ec;
187 const auto parent_status = std::filesystem::status(parent, parent_ec);
188 if (parent_ec || !std::filesystem::exists(parent_status) ||
189 !std::filesystem::is_directory(parent_status)) {
190 return absl::FailedPreconditionError(absl::StrFormat(
191 "Artifact parent directory must already exist: %s%s", parent.string(),
192 !parent_ec ? "" : absl::StrFormat(" (%s)", parent_ec.message())));
193 }
194 return resolved;
195}
196
197std::filesystem::path NextArtifactTempPath(
198 const std::filesystem::path& target_path) {
199 static std::atomic<uint64_t> sequence{0};
200 const uint64_t tick = static_cast<uint64_t>(
201 std::chrono::steady_clock::now().time_since_epoch().count());
202 const uint64_t id = sequence.fetch_add(1, std::memory_order_relaxed);
203
204 std::filesystem::path temp_name = target_path.filename();
205 temp_name += absl::StrFormat(".yaze-tmp-%016x-%016x", tick, id);
206 const auto parent = target_path.parent_path().empty()
207 ? std::filesystem::path(".")
208 : target_path.parent_path();
209 return parent / temp_name;
210}
211
212absl::StatusOr<std::filesystem::path> WriteExclusiveArtifactTemp(
213 const std::filesystem::path& target_path, absl::string_view content) {
214 constexpr int kMaxCreateAttempts = 100;
215
216 for (int attempt = 0; attempt < kMaxCreateAttempts; ++attempt) {
217 const std::filesystem::path temp_path = NextArtifactTempPath(target_path);
218
219#if defined(_WIN32)
220 HANDLE file = CreateFileW(
221 temp_path.wstring().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW,
222 FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH, nullptr);
223 if (file == INVALID_HANDLE_VALUE) {
224 const DWORD error = GetLastError();
225 if (error == ERROR_FILE_EXISTS || error == ERROR_ALREADY_EXISTS) {
226 continue;
227 }
228 return absl::PermissionDeniedError(absl::StrFormat(
229 "Cannot create temporary artifact for %s: %s", target_path.string(),
230 std::error_code(static_cast<int>(error), std::system_category())
231 .message()));
232 }
233
234 size_t written_total = 0;
235 while (written_total < content.size()) {
236 const size_t remaining = content.size() - written_total;
237 const DWORD chunk = static_cast<DWORD>(
238 std::min<size_t>(remaining, std::numeric_limits<DWORD>::max()));
239 DWORD written = 0;
240 if (!WriteFile(file, content.data() + written_total, chunk, &written,
241 nullptr) ||
242 written == 0) {
243 const DWORD error = GetLastError();
244 CloseHandle(file);
245 std::error_code cleanup_ec;
246 std::filesystem::remove(temp_path, cleanup_ec);
247 return absl::InternalError(absl::StrFormat(
248 "Failed while writing temporary artifact for %s: %s",
249 target_path.string(),
250 std::error_code(static_cast<int>(error), std::system_category())
251 .message()));
252 }
253 written_total += written;
254 }
255
256 if (!FlushFileBuffers(file)) {
257 const DWORD error = GetLastError();
258 CloseHandle(file);
259 std::error_code cleanup_ec;
260 std::filesystem::remove(temp_path, cleanup_ec);
261 return absl::InternalError(absl::StrFormat(
262 "Failed to flush temporary artifact for %s: %s", target_path.string(),
263 std::error_code(static_cast<int>(error), std::system_category())
264 .message()));
265 }
266 if (!CloseHandle(file)) {
267 const DWORD error = GetLastError();
268 std::error_code cleanup_ec;
269 std::filesystem::remove(temp_path, cleanup_ec);
270 return absl::InternalError(absl::StrFormat(
271 "Failed to close temporary artifact for %s: %s", target_path.string(),
272 std::error_code(static_cast<int>(error), std::system_category())
273 .message()));
274 }
275#else
276 const int fd =
277 open(temp_path.c_str(), O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR);
278 if (fd < 0) {
279 const int error = errno;
280 if (error == EEXIST) {
281 continue;
282 }
283 return absl::PermissionDeniedError(absl::StrFormat(
284 "Cannot create temporary artifact for %s: %s", target_path.string(),
285 std::error_code(error, std::generic_category()).message()));
286 }
287
288 size_t written_total = 0;
289 while (written_total < content.size()) {
290 const size_t remaining = content.size() - written_total;
291 const size_t chunk = std::min<size_t>(remaining, 1024 * 1024);
292 const ssize_t written = write(fd, content.data() + written_total, chunk);
293 if (written < 0 && errno == EINTR) {
294 continue;
295 }
296 if (written <= 0) {
297 const int error = written < 0 ? errno : EIO;
298 close(fd);
299 std::error_code cleanup_ec;
300 std::filesystem::remove(temp_path, cleanup_ec);
301 return absl::InternalError(absl::StrFormat(
302 "Failed while writing temporary artifact for %s: %s",
303 target_path.string(),
304 std::error_code(error, std::generic_category()).message()));
305 }
306 written_total += static_cast<size_t>(written);
307 }
308
309 if (fsync(fd) != 0) {
310 const int error = errno;
311 close(fd);
312 std::error_code cleanup_ec;
313 std::filesystem::remove(temp_path, cleanup_ec);
314 return absl::InternalError(absl::StrFormat(
315 "Failed to flush temporary artifact for %s: %s", target_path.string(),
316 std::error_code(error, std::generic_category()).message()));
317 }
318 if (close(fd) != 0) {
319 const int error = errno;
320 std::error_code cleanup_ec;
321 std::filesystem::remove(temp_path, cleanup_ec);
322 return absl::InternalError(absl::StrFormat(
323 "Failed to close temporary artifact for %s: %s", target_path.string(),
324 std::error_code(error, std::generic_category()).message()));
325 }
326#endif
327
328 return temp_path;
329 }
330
331 return absl::ResourceExhaustedError(
332 absl::StrFormat("Could not allocate a unique temporary artifact for %s",
333 target_path.string()));
334}
335
336absl::Status ReplaceArtifactFromTemp(const std::filesystem::path& temp_path,
337 const std::filesystem::path& target_path) {
338 std::error_code rename_ec;
339 std::filesystem::rename(temp_path, target_path, rename_ec);
340#if defined(_WIN32)
341 if (rename_ec) {
342 if (MoveFileExW(temp_path.wstring().c_str(), target_path.wstring().c_str(),
343 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
344 rename_ec.clear();
345 } else {
346 rename_ec = std::error_code(static_cast<int>(GetLastError()),
347 std::system_category());
348 }
349 }
350#endif
351 if (rename_ec) {
352 return absl::InternalError(
353 absl::StrFormat("Failed to publish artifact %s: %s",
354 target_path.string(), rename_ec.message()));
355 }
356 return absl::OkStatus();
357}
358
359absl::Status ValidatePublishTarget(const std::filesystem::path& target_path) {
360 std::error_code status_ec;
361 const auto status = std::filesystem::symlink_status(target_path, status_ec);
362 if (status_ec && status_ec != std::errc::no_such_file_or_directory) {
363 return absl::FailedPreconditionError(
364 absl::StrFormat("Cannot inspect artifact target %s: %s",
365 target_path.string(), status_ec.message()));
366 }
367 if (!status_ec && std::filesystem::is_directory(status)) {
368 return absl::FailedPreconditionError(
369 absl::StrFormat("Artifact target is a directory, not a file: %s",
370 target_path.string()));
371 }
372 if (!status_ec && std::filesystem::exists(status) &&
373 !std::filesystem::is_regular_file(status) &&
374 !std::filesystem::is_symlink(status)) {
375 return absl::FailedPreconditionError(absl::StrFormat(
376 "Artifact target is not a replaceable file: %s", target_path.string()));
377 }
378 return absl::OkStatus();
379}
380
381absl::StatusOr<std::optional<std::filesystem::path>> CreateArtifactRollbackCopy(
382 const std::filesystem::path& target_path) {
383 std::error_code status_ec;
384 const auto status = std::filesystem::symlink_status(target_path, status_ec);
385 if (status_ec == std::errc::no_such_file_or_directory ||
386 (!status_ec && !std::filesystem::exists(status))) {
387 return std::nullopt;
388 }
389 if (status_ec) {
390 return absl::FailedPreconditionError(
391 absl::StrFormat("Cannot inspect existing artifact %s: %s",
392 target_path.string(), status_ec.message()));
393 }
394
395 constexpr int kMaxCreateAttempts = 100;
396 for (int attempt = 0; attempt < kMaxCreateAttempts; ++attempt) {
397 const std::filesystem::path rollback_path =
398 NextArtifactTempPath(target_path);
399 std::error_code copy_ec;
400 if (std::filesystem::is_symlink(status)) {
401 std::filesystem::copy_symlink(target_path, rollback_path, copy_ec);
402 } else {
403 std::filesystem::copy_file(target_path, rollback_path,
404 std::filesystem::copy_options::none, copy_ec);
405 }
406 if (!copy_ec) {
407 return rollback_path;
408 }
409 if (copy_ec == std::errc::file_exists) {
410 continue;
411 }
412 return absl::FailedPreconditionError(absl::StrFormat(
413 "Cannot preserve existing artifact %s before publication: %s",
414 target_path.string(), copy_ec.message()));
415 }
416
417 return absl::ResourceExhaustedError(
418 absl::StrFormat("Could not allocate a rollback copy for artifact %s",
419 target_path.string()));
420}
421
423 std::filesystem::path target_path;
424 std::string content;
425 std::filesystem::path temp_path;
426 std::optional<std::filesystem::path> rollback_path;
427 bool published = false;
428 bool preserve_rollback_copy = false;
429};
430
432 const std::vector<PendingArtifactPublication>& artifacts) {
433 for (const auto& artifact : artifacts) {
434 std::error_code cleanup_ec;
435 if (!artifact.temp_path.empty()) {
436 std::filesystem::remove(artifact.temp_path, cleanup_ec);
437 }
438 if (artifact.rollback_path.has_value() &&
439 !artifact.preserve_rollback_copy) {
440 cleanup_ec.clear();
441 std::filesystem::remove(*artifact.rollback_path, cleanup_ec);
442 }
443 }
444}
445
447 std::vector<PendingArtifactPublication>* artifacts,
448 const absl::Status& publication_failure) {
449 absl::Status rollback_failure = absl::OkStatus();
450 for (auto it = artifacts->rbegin(); it != artifacts->rend(); ++it) {
451 if (!it->published) {
452 continue;
453 }
454
455 if (it->rollback_path.has_value()) {
456 const absl::Status restore_status =
457 ReplaceArtifactFromTemp(*it->rollback_path, it->target_path);
458 if (!restore_status.ok() && rollback_failure.ok()) {
459 it->preserve_rollback_copy = true;
460 rollback_failure = absl::InternalError(absl::StrFormat(
461 "%s; preserved the original artifact at %s",
462 restore_status.message(), it->rollback_path->string()));
463 } else if (!restore_status.ok()) {
464 it->preserve_rollback_copy = true;
465 } else if (restore_status.ok()) {
466 it->rollback_path.reset();
467 }
468 } else {
469 std::error_code remove_ec;
470 if (!std::filesystem::remove(it->target_path, remove_ec) || remove_ec) {
471 if (rollback_failure.ok()) {
472 rollback_failure = absl::InternalError(absl::StrFormat(
473 "Could not remove newly published artifact %s during rollback: "
474 "%s",
475 it->target_path.string(),
476 remove_ec ? remove_ec.message() : "artifact was missing"));
477 }
478 }
479 }
480 it->published = false;
481 }
482
483 if (!rollback_failure.ok()) {
484 return absl::DataLossError(absl::StrFormat(
485 "Artifact publication failed (%s) and rollback failed (%s)",
486 publication_failure.message(), rollback_failure.message()));
487 }
488 return publication_failure;
489}
490
492 const std::vector<PendingArtifactPublication>& artifacts) {
493 for (size_t lhs_index = 0; lhs_index < artifacts.size(); ++lhs_index) {
494 for (size_t rhs_index = lhs_index + 1; rhs_index < artifacts.size();
495 ++rhs_index) {
496 std::error_code equivalent_ec;
497 if (std::filesystem::equivalent(artifacts[lhs_index].target_path,
498 artifacts[rhs_index].target_path,
499 equivalent_ec)) {
500 return absl::InvalidArgumentError(absl::StrFormat(
501 "Export artifact paths alias each other on this filesystem: %s "
502 "and %s",
503 artifacts[lhs_index].target_path.string(),
504 artifacts[rhs_index].target_path.string()));
505 }
506
507 if (!equivalent_ec) {
508 continue;
509 }
510
511 // A just-published target makes native case/Unicode aliases exist under
512 // both spellings. If both spellings now exist but the filesystem cannot
513 // compare them, fail closed and roll the publication back.
514 std::error_code lhs_exists_ec;
515 std::error_code rhs_exists_ec;
516 const bool lhs_exists = std::filesystem::exists(
517 artifacts[lhs_index].target_path, lhs_exists_ec);
518 const bool rhs_exists = std::filesystem::exists(
519 artifacts[rhs_index].target_path, rhs_exists_ec);
520 if (lhs_exists_ec || rhs_exists_ec || (lhs_exists && rhs_exists)) {
521 return absl::FailedPreconditionError(absl::StrFormat(
522 "Could not safely distinguish export artifact paths %s and %s: "
523 "%s",
524 artifacts[lhs_index].target_path.string(),
525 artifacts[rhs_index].target_path.string(),
526 equivalent_ec.message()));
527 }
528 }
529 }
530 return absl::OkStatus();
531}
532
534 std::vector<PendingArtifactPublication> artifacts) {
535 for (const auto& artifact : artifacts) {
536 const absl::Status target_status =
537 ValidatePublishTarget(artifact.target_path);
538 if (!target_status.ok()) {
540 return target_status;
541 }
542 }
543
544 for (auto& artifact : artifacts) {
545 auto temp_or =
546 WriteExclusiveArtifactTemp(artifact.target_path, artifact.content);
547 if (!temp_or.ok()) {
549 return temp_or.status();
550 }
551 artifact.temp_path = *temp_or;
552 }
553
554 for (auto& artifact : artifacts) {
555 auto rollback_or = CreateArtifactRollbackCopy(artifact.target_path);
556 if (!rollback_or.ok()) {
558 return rollback_or.status();
559 }
560 artifact.rollback_path = std::move(*rollback_or);
561 }
562
563 for (auto& artifact : artifacts) {
564 const absl::Status publish_status =
565 ReplaceArtifactFromTemp(artifact.temp_path, artifact.target_path);
566 if (!publish_status.ok()) {
567 const absl::Status final_status =
568 RollBackPublishedArtifacts(&artifacts, publish_status);
570 return final_status;
571 }
572 artifact.temp_path.clear();
573 artifact.published = true;
574
575 const absl::Status alias_status =
577 if (!alias_status.ok()) {
578 const absl::Status final_status =
579 RollBackPublishedArtifacts(&artifacts, alias_status);
581 return final_status;
582 }
583 }
584
586 return absl::OkStatus();
587}
588
590 const std::filesystem::path& target_path, absl::string_view content) {
591 std::vector<PendingArtifactPublication> artifacts;
592 artifacts.push_back(PendingArtifactPublication{
593 .target_path = target_path,
594 .content = std::string(content),
595 });
596 return PublishArtifactSetAtomically(std::move(artifacts));
597}
598
600 const std::filesystem::path& out_path, absl::string_view out_content,
601 const std::optional<std::filesystem::path>& report_path,
602 absl::string_view report_content) {
603 // Callers pass paths resolved and ROM-checked at command start. Do not follow
604 // the caller's raw path again after a parent symlink could have changed.
605 std::vector<PendingArtifactPublication> artifacts;
606 artifacts.push_back(PendingArtifactPublication{
607 .target_path = out_path,
608 .content = std::string(out_content),
609 });
610 if (report_path.has_value()) {
611 artifacts.push_back(PendingArtifactPublication{
612 .target_path = *report_path,
613 .content = std::string(report_content),
614 });
615 }
616 return PublishArtifactSetAtomically(std::move(artifacts));
617}
618
620 absl::string_view command_name) {
621 RETURN_IF_ERROR(parser.RequireArgs({"in"}));
622 const auto report_path = parser.GetString("report");
623 const bool has_report = report_path.has_value();
624 if (has_report && report_path->empty()) {
625 return absl::InvalidArgumentError(
626 absl::StrFormat("%s: --report cannot be empty", command_name));
627 }
628 const bool sandbox_requested =
629 parser.HasFlag("sandbox") || absl::GetFlag(FLAGS_sandbox);
630 if (has_report && !parser.HasFlag("dry-run")) {
631 return absl::InvalidArgumentError(absl::StrFormat(
632 "%s: --report is supported only with --dry-run; write-mode imports "
633 "must rely on command output and ROM backup verification",
634 command_name));
635 }
636 if (has_report && sandbox_requested) {
637 return absl::InvalidArgumentError(absl::StrFormat(
638 "%s: --report cannot be combined with --sandbox; run the reported "
639 "dry-run against the source ROM",
640 command_name));
641 }
642 if (parser.HasFlag("mock-rom") && sandbox_requested) {
643 return absl::InvalidArgumentError(absl::StrFormat(
644 "%s: --mock-rom and --sandbox are mutually exclusive", command_name));
645 }
646 return absl::OkStatus();
647}
648
650 absl::string_view command_name) {
651 RETURN_IF_ERROR(parser.RequireArgs({"out"}));
652 if (parser.GetString("out")->empty()) {
653 return absl::InvalidArgumentError(
654 absl::StrFormat("%s: --out cannot be empty", command_name));
655 }
656 if (const auto report = parser.GetString("report");
657 report.has_value() && report->empty()) {
658 return absl::InvalidArgumentError(
659 absl::StrFormat("%s: --report cannot be empty", command_name));
660 }
661 return absl::OkStatus();
662}
663
664absl::StatusOr<std::filesystem::path> NormalizedAbsolutePath(
665 const std::filesystem::path& path) {
666 return ResolveStableArtifactPath(path);
667}
668
669absl::StatusOr<bool> PathsAlias(const std::filesystem::path& lhs,
670 const std::filesystem::path& rhs) {
671 ASSIGN_OR_RETURN(const auto normalized_lhs, NormalizedAbsolutePath(lhs));
672 ASSIGN_OR_RETURN(const auto normalized_rhs, NormalizedAbsolutePath(rhs));
673 if (normalized_lhs == normalized_rhs) {
674 return true;
675 }
676
677 std::error_code equivalent_ec;
678 const bool equivalent = std::filesystem::equivalent(
679 normalized_lhs, normalized_rhs, equivalent_ec);
680 if (!equivalent_ec) {
681 return equivalent;
682 }
683
684 // equivalent() reports an error when either path does not exist. Lexical
685 // normalization above is sufficient in that case. If both paths do exist,
686 // fail closed rather than risk truncating a ROM we could not compare.
687 std::error_code lhs_exists_ec;
688 std::error_code rhs_exists_ec;
689 const bool lhs_exists =
690 std::filesystem::exists(normalized_lhs, lhs_exists_ec);
691 const bool rhs_exists =
692 std::filesystem::exists(normalized_rhs, rhs_exists_ec);
693 if (lhs_exists_ec || rhs_exists_ec || (lhs_exists && rhs_exists)) {
694 return absl::FailedPreconditionError(absl::StrFormat(
695 "Could not safely compare paths %s and %s: %s", normalized_lhs.string(),
696 normalized_rhs.string(), equivalent_ec.message()));
697 }
698 return false;
699}
700
702 absl::string_view option_name, const std::filesystem::path& artifact_path,
703 const resources::CommandInvocationContext& invocation_context) {
704 if (invocation_context.active_rom_path.has_value()) {
706 const bool aliases_active_rom,
707 PathsAlias(artifact_path, *invocation_context.active_rom_path));
708 if (aliases_active_rom) {
709 return absl::InvalidArgumentError(absl::StrFormat(
710 "%s path aliases the active ROM; choose a separate artifact file: "
711 "%s",
712 option_name, artifact_path.string()));
713 }
714 }
715
716 if (invocation_context.source_rom_path.has_value()) {
718 const bool aliases_source_rom,
719 PathsAlias(artifact_path, *invocation_context.source_rom_path));
720 if (aliases_source_rom) {
721 return absl::InvalidArgumentError(absl::StrFormat(
722 "%s path aliases the %s; choose a separate artifact file: %s",
723 option_name,
724 invocation_context.sandbox_enabled ? "sandbox source ROM"
725 : "source ROM",
726 artifact_path.string()));
727 }
728 }
729
730 return absl::OkStatus();
731}
732
734 std::filesystem::path out_path;
735 std::optional<std::filesystem::path> report_path;
736
737 bool operator==(const ResolvedExportArtifactPaths&) const = default;
738};
739
740absl::StatusOr<ResolvedExportArtifactPaths> ResolveExportArtifactPaths(
741 const resources::ArgumentParser& parser,
742 const resources::CommandInvocationContext& invocation_context) {
743 const auto out_path_value = parser.GetString("out");
744 if (!out_path_value.has_value() || out_path_value->empty()) {
745 return absl::InvalidArgumentError("--out must name an artifact file");
746 }
747
748 ASSIGN_OR_RETURN(const auto out_path,
749 ResolveStableArtifactPath(*out_path_value));
751 RejectArtifactRomAliases("--out", out_path, invocation_context));
752
753 ResolvedExportArtifactPaths resolved_paths{.out_path = out_path};
754
755 const auto report_path_value = parser.GetString("report");
756 if (!report_path_value.has_value()) {
757 return resolved_paths;
758 }
759 if (report_path_value->empty()) {
760 return absl::InvalidArgumentError("--report must name an artifact file");
761 }
762
763 ASSIGN_OR_RETURN(const auto report_path,
764 ResolveStableArtifactPath(*report_path_value));
766 RejectArtifactRomAliases("--report", report_path, invocation_context));
767
768 ASSIGN_OR_RETURN(const bool artifacts_alias,
769 PathsAlias(out_path, report_path));
770 if (artifacts_alias) {
771 return absl::InvalidArgumentError(absl::StrFormat(
772 "--out and --report paths alias each other; choose separate artifact "
773 "files: %s",
774 out_path.string()));
775 }
776
777 resolved_paths.report_path = report_path;
778 return resolved_paths;
779}
780
781absl::StatusOr<std::optional<std::filesystem::path>> ResolveReportArtifactPath(
782 const resources::ArgumentParser& parser, const Rom* rom) {
783 const auto report_path_value = parser.GetString("report");
784 if (!report_path_value.has_value() || report_path_value->empty()) {
785 return std::nullopt;
786 }
787
788 ASSIGN_OR_RETURN(const auto report_path,
789 ResolveStableArtifactPath(*report_path_value));
790 if (rom != nullptr && !rom->filename().empty()) {
792 const bool aliases_active_rom,
793 PathsAlias(report_path, std::filesystem::path(rom->filename())));
794 if (aliases_active_rom) {
795 return absl::InvalidArgumentError(absl::StrFormat(
796 "--report path aliases the active ROM; choose a separate report "
797 "file: %s",
798 report_path.string()));
799 }
800 }
801
802 return report_path;
803}
804
805std::string StatusCodeName(absl::StatusCode code) {
806 switch (code) {
807 case absl::StatusCode::kOk:
808 return "OK";
809 case absl::StatusCode::kCancelled:
810 return "CANCELLED";
811 case absl::StatusCode::kUnknown:
812 return "UNKNOWN";
813 case absl::StatusCode::kInvalidArgument:
814 return "INVALID_ARGUMENT";
815 case absl::StatusCode::kDeadlineExceeded:
816 return "DEADLINE_EXCEEDED";
817 case absl::StatusCode::kNotFound:
818 return "NOT_FOUND";
819 case absl::StatusCode::kAlreadyExists:
820 return "ALREADY_EXISTS";
821 case absl::StatusCode::kPermissionDenied:
822 return "PERMISSION_DENIED";
823 case absl::StatusCode::kResourceExhausted:
824 return "RESOURCE_EXHAUSTED";
825 case absl::StatusCode::kFailedPrecondition:
826 return "FAILED_PRECONDITION";
827 case absl::StatusCode::kAborted:
828 return "ABORTED";
829 case absl::StatusCode::kOutOfRange:
830 return "OUT_OF_RANGE";
831 case absl::StatusCode::kUnimplemented:
832 return "UNIMPLEMENTED";
833 case absl::StatusCode::kInternal:
834 return "INTERNAL";
835 case absl::StatusCode::kUnavailable:
836 return "UNAVAILABLE";
837 case absl::StatusCode::kDataLoss:
838 return "DATA_LOSS";
839 case absl::StatusCode::kUnauthenticated:
840 return "UNAUTHENTICATED";
841 }
842 return "UNKNOWN";
843}
844
845json BuildBaseReport(absl::string_view command_name, bool dry_run) {
846 return json{
847 {"command", std::string(command_name)},
848 {"status", "success"},
849 {"dry_run", dry_run},
850 {"mode", dry_run ? "dry-run" : "write"},
851 };
852}
853
855 const absl::Status& status) {
856 formatter.AddField("status", "error");
857 formatter.BeginObject("error");
858 formatter.AddField("code", StatusCodeName(status.code()));
859 formatter.AddField("message", std::string(status.message()));
860 formatter.EndObject();
861}
862
864 const std::optional<std::filesystem::path>& resolved_report_path,
865 json report, const absl::Status& status) {
866 if (!status.ok()) {
867 report["status"] = "error";
868 report["error"] = json{
869 {"code", StatusCodeName(status.code())},
870 {"message", std::string(status.message())},
871 };
872 }
873
874 absl::Status report_status = absl::OkStatus();
875 if (resolved_report_path.has_value()) {
876 report_status = PublishResolvedTextFileAtomically(*resolved_report_path,
877 report.dump(2) + "\n");
878 }
879 if (!report_status.ok()) {
880 if (status.ok()) {
881 return report_status;
882 }
883 return absl::InternalError(
884 absl::StrFormat("Command failed (%s) and report write failed (%s)",
885 status.message(), report_status.message()));
886 }
887
888 return status;
889}
890
892 const ResolvedExportArtifactPaths& artifact_paths, json report,
893 const absl::Status& status) {
894 return FinalizeWithReport(artifact_paths.report_path, std::move(report),
895 status);
896}
897
900 json out;
901 out["ok"] = preflight.ok();
902 json errors = json::array();
903 for (const auto& err : preflight.errors) {
904 json e;
905 e["code"] = err.code;
906 e["message"] = err.message;
907 e["status_code"] = StatusCodeName(err.status_code);
908 if (err.room_id >= 0) {
909 e["room_id"] = absl::StrFormat("0x%02X", err.room_id);
910 }
911 errors.push_back(std::move(e));
912 }
913 out["errors"] = std::move(errors);
914 return out;
915}
916
917template <typename Serializer>
919 const resources::ArgumentParser& parser,
920 json* report, Serializer&& serializer) {
921 ScopedRomTransaction transaction(*rom);
922
923 const absl::Status write_status = std::forward<Serializer>(serializer)();
924 if (!write_status.ok()) {
925 (*report)["write_error"] = std::string(write_status.message());
926 return write_status;
927 }
928 (*report)["write_status"] = "success";
929
930 // Unit-test and embedding callers can explicitly request an in-memory write.
931 // A sandbox ROM is file-backed, so it follows the normal save path and writes
932 // only its sandbox copy.
933 if (parser.HasFlag("mock-rom")) {
934 (*report)["save_status"] = "mock-rom-skipped";
935 transaction.Commit();
936 return absl::OkStatus();
937 }
938
939 Rom::SaveSettings save_settings;
940 save_settings.require_backup = true;
941 const absl::Status save_status = rom->SaveToFile(save_settings);
942 if (!save_status.ok()) {
943 (*report)["save_error"] = std::string(save_status.message());
944 return save_status;
945 }
946
947 (*report)["save_status"] = "saved";
948 transaction.Commit();
949 return absl::OkStatus();
950}
951
953 const std::vector<zelda3::WaterFillZoneEntry>& zones) {
954 constexpr std::array<int, 2> kD4RoomIdsRequiringCollision = {0x25, 0x27};
955
956 std::unordered_set<int> imported_rooms;
957 imported_rooms.reserve(zones.size());
958 for (const auto& zone : zones) {
959 imported_rooms.insert(zone.room_id);
960 }
961
962 std::vector<int> required_rooms;
963 for (int room_id : kD4RoomIdsRequiringCollision) {
964 if (imported_rooms.contains(room_id)) {
965 required_rooms.push_back(room_id);
966 }
967 }
968 return required_rooms;
969}
970
971} // namespace
972
974 Rom* rom, const resources::ArgumentParser& parser,
975 resources::OutputFormatter& formatter) {
976 auto room_id_str = parser.GetString("room").value();
977
978 int room_id = 0;
979 if (!ParseHexString(room_id_str, &room_id)) {
980 return absl::InvalidArgumentError("Invalid room ID format. Must be hex.");
981 }
982
983 ASSIGN_OR_RETURN(auto filter_tiles, ParseTileFilter(parser));
984
985 const bool list_all = parser.HasFlag("all");
986 const bool list_nonzero =
987 parser.HasFlag("nonzero") || (!list_all && filter_tiles.empty());
988
989 formatter.BeginObject("Dungeon Custom Collision");
990 formatter.AddField("room_id", room_id);
991 formatter.AddHexField("room_id_hex", room_id, 2);
992 formatter.AddField(
993 "filter_mode",
994 !filter_tiles.empty()
995 ? "tiles"
996 : (list_all ? "all" : (list_nonzero ? "nonzero" : "all")));
997
998 auto map_or = zelda3::LoadCustomCollisionMap(rom, room_id);
999 if (!map_or.ok()) {
1000 formatter.AddField("status", "error");
1001 formatter.AddField("error", map_or.status().ToString());
1002 formatter.EndObject();
1003 return map_or.status();
1004 }
1005
1006 const auto& map = map_or.value();
1007 formatter.AddField("has_data", map.has_data);
1008
1009 int nonzero_count = 0;
1010 for (uint8_t tile : map.tiles) {
1011 if (tile != 0) {
1012 ++nonzero_count;
1013 }
1014 }
1015 formatter.AddField("nonzero_tiles", nonzero_count);
1016
1017 formatter.BeginArray("tiles");
1018 int match_count = 0;
1019 if (map.has_data) {
1020 for (int y = 0; y < 64; ++y) {
1021 for (int x = 0; x < 64; ++x) {
1022 uint8_t tile = map.tiles[static_cast<size_t>(y * 64 + x)];
1023
1024 if (!filter_tiles.empty()) {
1025 if (filter_tiles.find(static_cast<int>(tile)) == filter_tiles.end()) {
1026 continue;
1027 }
1028 } else if (list_nonzero) {
1029 if (tile == 0) {
1030 continue;
1031 }
1032 } else if (!list_all) {
1033 // Default behavior if neither filter nor flags are set is nonzero.
1034 if (tile == 0) {
1035 continue;
1036 }
1037 }
1038
1039 formatter.BeginObject();
1040 formatter.AddField("x", x);
1041 formatter.AddField("y", y);
1042 formatter.AddHexField("tile", tile, 2);
1043 formatter.EndObject();
1044 ++match_count;
1045 }
1046 }
1047 }
1048 formatter.EndArray();
1049
1050 formatter.AddField("match_count", match_count);
1051 formatter.AddField("status", "success");
1052 formatter.EndObject();
1053 return absl::OkStatus();
1054}
1055
1057 Rom* rom, const resources::ArgumentParser& parser,
1058 resources::OutputFormatter& formatter) {
1059 resources::CommandInvocationContext invocation_context;
1060 if (rom != nullptr && !rom->filename().empty()) {
1061 invocation_context.source_rom_path = std::filesystem::path(rom->filename());
1062 invocation_context.active_rom_path = std::filesystem::path(rom->filename());
1063 }
1064 return ExecuteWithContext(rom, parser, formatter, invocation_context);
1065}
1066
1068 Rom* rom, const resources::ArgumentParser& parser,
1069 resources::OutputFormatter& formatter,
1070 const resources::CommandInvocationContext& invocation_context) {
1071 auto artifact_paths_or =
1072 ResolveExportArtifactPaths(parser, invocation_context);
1073 if (!artifact_paths_or.ok()) {
1074 AddStructuredError(formatter, artifact_paths_or.status());
1075 return artifact_paths_or.status();
1076 }
1077 const ResolvedExportArtifactPaths artifact_paths = *artifact_paths_or;
1078
1079 json report = BuildBaseReport(GetName(), /*dry_run=*/false);
1080 std::string out_path;
1081 std::string exported_json;
1082 int requested_rooms = 0;
1083 int exported_room_count = 0;
1084 const absl::Status status = [&]() -> absl::Status {
1085 ASSIGN_OR_RETURN(const auto room_ids, ParseRoomSelection(parser));
1086 out_path = parser.GetString("out").value();
1087 requested_rooms = static_cast<int>(room_ids.size());
1088 report["out_path"] = out_path;
1089 report["requested_rooms"] = requested_rooms;
1090
1091 std::vector<zelda3::CustomCollisionRoomEntry> export_rooms;
1092 export_rooms.reserve(room_ids.size());
1093
1094 for (int room_id : room_ids) {
1095 ASSIGN_OR_RETURN(auto map, zelda3::LoadCustomCollisionMap(rom, room_id));
1096 if (!map.has_data) {
1097 continue;
1098 }
1099
1101 entry.room_id = room_id;
1102 for (int offset = 0; offset < kCollisionGridSize * kCollisionGridSize;
1103 ++offset) {
1104 const uint8_t tile = map.tiles[static_cast<size_t>(offset)];
1105 if (tile == 0) {
1106 continue;
1107 }
1109 static_cast<uint16_t>(offset), tile});
1110 }
1111 if (!entry.tiles.empty()) {
1112 export_rooms.push_back(std::move(entry));
1113 }
1114 }
1115
1117 exported_json,
1119 exported_room_count = static_cast<int>(export_rooms.size());
1120 report["exported_rooms"] = exported_room_count;
1121
1122 return absl::OkStatus();
1123 }();
1124
1125 // Resolve again after the export body as defense-in-depth against path
1126 // identity changes between initial validation and publication.
1127 auto final_artifact_paths_or =
1128 ResolveExportArtifactPaths(parser, invocation_context);
1129 absl::Status final_path_status = final_artifact_paths_or.status();
1130 if (final_artifact_paths_or.ok() &&
1131 *final_artifact_paths_or != artifact_paths) {
1132 final_path_status = absl::FailedPreconditionError(
1133 "Export artifact path identity changed during the command; no "
1134 "artifact was published");
1135 }
1136
1137 absl::Status final_status;
1138 if (!status.ok()) {
1139 // Do not let a coincident path re-resolution failure shadow the original
1140 // business error. An unsafe final path suppresses report publication, but
1141 // callers still receive the command failure that stopped the export.
1142 final_status = final_path_status.ok()
1143 ? FinalizeExportWithReport(artifact_paths,
1144 std::move(report), status)
1145 : status;
1146 } else if (!final_path_status.ok()) {
1147 final_status = final_path_status;
1148 } else {
1149 final_status = PublishExportArtifactsAtomically(
1150 artifact_paths.out_path, exported_json, artifact_paths.report_path,
1151 report.dump(2) + "\n");
1152 }
1153 if (!final_status.ok()) {
1154 AddStructuredError(formatter, final_status);
1155 return final_status;
1156 }
1157
1158 formatter.BeginObject("Custom Collision Export");
1159 formatter.AddField("out_path", out_path);
1160 formatter.AddField("requested_rooms", requested_rooms);
1161 formatter.AddField("exported_rooms", exported_room_count);
1162 formatter.AddField("status", "success");
1163 formatter.EndObject();
1164 return absl::OkStatus();
1165}
1166
1168 Rom* rom, const resources::ArgumentParser& parser,
1169 resources::OutputFormatter& formatter) {
1170 auto report_path_or = ResolveReportArtifactPath(parser, rom);
1171 if (!report_path_or.ok()) {
1172 AddStructuredError(formatter, report_path_or.status());
1173 return report_path_or.status();
1174 }
1175 const std::optional<std::filesystem::path> report_path = *report_path_or;
1176
1177 const bool dry_run = parser.HasFlag("dry-run");
1178 json report = BuildBaseReport(GetName(), dry_run);
1179 const absl::Status status = [&]() -> absl::Status {
1180 const std::string in_path = parser.GetString("in").value();
1181 const bool replace_all = parser.HasFlag("replace-all");
1182 const bool force = parser.HasFlag("force");
1183 report["in_path"] = in_path;
1184 report["replace_all"] = replace_all;
1185 report["force"] = force;
1186
1187 if (!zelda3::HasCustomCollisionWriteSupport(rom->vector().size())) {
1188 return absl::FailedPreconditionError(
1189 "Custom collision write support not present in this ROM");
1190 }
1191
1193 preflight_options.require_water_fill_reserved_region = true;
1194 preflight_options.require_custom_collision_write_support = true;
1195 preflight_options.validate_water_fill_table = true;
1196 preflight_options.validate_custom_collision_maps = true;
1197 const auto preflight =
1198 zelda3::RunOracleRomSafetyPreflight(rom, preflight_options);
1199 report["preflight"] = BuildPreflightJson(preflight);
1200 if (!preflight.ok()) {
1201 return preflight.ToStatus();
1202 }
1203
1204 if (replace_all && !dry_run && !force) {
1205 return absl::FailedPreconditionError(
1206 "--replace-all requires --force (run with --dry-run first)");
1207 }
1208
1209 ASSIGN_OR_RETURN(const std::string json_content, ReadTextFile(in_path));
1211 auto imported_rooms,
1213 report["imported_room_entries"] = static_cast<int>(imported_rooms.size());
1214
1215 // Keep room storage on the heap: zelda3::Room is large enough that a full
1216 // `kNumberOfRooms` array can overflow stack frames in optimized builds.
1217 std::vector<zelda3::Room> rooms;
1218 rooms.reserve(zelda3::kNumberOfRooms);
1219 for (int room_id = 0; room_id < zelda3::kNumberOfRooms; ++room_id) {
1220 rooms.emplace_back(room_id, rom, nullptr);
1221 }
1222
1223 int populated_rooms = 0;
1224 int cleared_rooms = 0;
1225 std::unordered_set<int> touched_rooms;
1226 for (const auto& imported : imported_rooms) {
1227 touched_rooms.insert(imported.room_id);
1228 auto& room = rooms[imported.room_id];
1229 room.custom_collision().tiles.fill(0);
1230 room.custom_collision().has_data = false;
1231 room.MarkCustomCollisionDirty();
1232
1233 bool has_nonzero = false;
1234 for (const auto& tile : imported.tiles) {
1235 const int offset = static_cast<int>(tile.offset);
1236 if (offset < 0 || offset >= kCollisionGridSize * kCollisionGridSize) {
1237 continue;
1238 }
1239 if (tile.value == 0) {
1240 continue;
1241 }
1242 room.SetCollisionTile(offset % kCollisionGridSize,
1243 offset / kCollisionGridSize, tile.value);
1244 has_nonzero = true;
1245 }
1246
1247 if (has_nonzero) {
1248 ++populated_rooms;
1249 } else {
1250 ++cleared_rooms;
1251 }
1252 }
1253
1254 int replace_all_clears = 0;
1255 if (replace_all) {
1256 for (int room_id = 0; room_id < zelda3::kNumberOfRooms; ++room_id) {
1257 if (touched_rooms.contains(room_id)) {
1258 continue;
1259 }
1260 auto& room = rooms[room_id];
1261 room.custom_collision().tiles.fill(0);
1262 room.custom_collision().has_data = false;
1263 room.MarkCustomCollisionDirty();
1264 ++cleared_rooms;
1265 ++replace_all_clears;
1266 }
1267 }
1268 report["replace_all_clears"] = replace_all_clears;
1269
1270 if (!dry_run) {
1271 RETURN_IF_ERROR(SerializeAndPersistImport(rom, parser, &report, [&]() {
1272 return zelda3::SaveAllCollision(rom, absl::MakeSpan(rooms));
1273 }));
1274 }
1275
1276 report["populated_rooms"] = populated_rooms;
1277 report["cleared_rooms"] = cleared_rooms;
1278
1279 formatter.BeginObject("Custom Collision Import");
1280 formatter.AddField("in_path", in_path);
1281 formatter.AddField("replace_all", replace_all);
1282 formatter.AddField("force", force);
1283 formatter.AddField("mode", dry_run ? "dry-run" : "write");
1284 formatter.AddField("imported_room_entries",
1285 static_cast<int>(imported_rooms.size()));
1286 formatter.AddField("populated_rooms", populated_rooms);
1287 formatter.AddField("cleared_rooms", cleared_rooms);
1288 if (!dry_run) {
1289 formatter.AddField("write_status",
1290 report.value("write_status", std::string("unknown")));
1291 formatter.AddField("save_status",
1292 report.value("save_status", std::string("unknown")));
1293 }
1294 formatter.AddField("status", "success");
1295 formatter.EndObject();
1296 return absl::OkStatus();
1297 }();
1298
1299 return FinalizeWithReport(report_path, std::move(report), status);
1300}
1301
1303 Rom* rom, const resources::ArgumentParser& parser,
1304 resources::OutputFormatter& formatter) {
1305 resources::CommandInvocationContext invocation_context;
1306 if (rom != nullptr && !rom->filename().empty()) {
1307 invocation_context.source_rom_path = std::filesystem::path(rom->filename());
1308 invocation_context.active_rom_path = std::filesystem::path(rom->filename());
1309 }
1310 return ExecuteWithContext(rom, parser, formatter, invocation_context);
1311}
1312
1314 Rom* rom, const resources::ArgumentParser& parser,
1315 resources::OutputFormatter& formatter,
1316 const resources::CommandInvocationContext& invocation_context) {
1317 auto artifact_paths_or =
1318 ResolveExportArtifactPaths(parser, invocation_context);
1319 if (!artifact_paths_or.ok()) {
1320 AddStructuredError(formatter, artifact_paths_or.status());
1321 return artifact_paths_or.status();
1322 }
1323 const ResolvedExportArtifactPaths artifact_paths = *artifact_paths_or;
1324
1325 json report = BuildBaseReport(GetName(), /*dry_run=*/false);
1326 std::string out_path;
1327 std::string exported_json;
1328 int requested_rooms = 0;
1329 int exported_zone_count = 0;
1330 const absl::Status status = [&]() -> absl::Status {
1331 out_path = parser.GetString("out").value();
1332 ASSIGN_OR_RETURN(const auto room_ids, ParseRoomSelection(parser));
1333 requested_rooms = static_cast<int>(room_ids.size());
1334 report["out_path"] = out_path;
1335 report["requested_rooms"] = requested_rooms;
1336
1337 if (!zelda3::HasWaterFillReservedRegion(rom->vector().size())) {
1338 return absl::FailedPreconditionError(
1339 "WaterFill reserved region missing in this ROM");
1340 }
1341
1343 std::unordered_set<int> room_filter(room_ids.begin(), room_ids.end());
1344 std::vector<zelda3::WaterFillZoneEntry> filtered;
1345 filtered.reserve(zones.size());
1346 for (const auto& zone : zones) {
1347 if (!room_filter.contains(zone.room_id)) {
1348 continue;
1349 }
1350 filtered.push_back(zone);
1351 }
1352
1353 ASSIGN_OR_RETURN(exported_json,
1355 exported_zone_count = static_cast<int>(filtered.size());
1356 report["exported_zones"] = exported_zone_count;
1357
1358 return absl::OkStatus();
1359 }();
1360
1361 // Resolve again after the export body as defense-in-depth against path
1362 // identity changes between initial validation and publication.
1363 auto final_artifact_paths_or =
1364 ResolveExportArtifactPaths(parser, invocation_context);
1365 absl::Status final_path_status = final_artifact_paths_or.status();
1366 if (final_artifact_paths_or.ok() &&
1367 *final_artifact_paths_or != artifact_paths) {
1368 final_path_status = absl::FailedPreconditionError(
1369 "Export artifact path identity changed during the command; no "
1370 "artifact was published");
1371 }
1372
1373 absl::Status final_status;
1374 if (!status.ok()) {
1375 // Do not let a coincident path re-resolution failure shadow the original
1376 // business error. An unsafe final path suppresses report publication, but
1377 // callers still receive the command failure that stopped the export.
1378 final_status = final_path_status.ok()
1379 ? FinalizeExportWithReport(artifact_paths,
1380 std::move(report), status)
1381 : status;
1382 } else if (!final_path_status.ok()) {
1383 final_status = final_path_status;
1384 } else {
1385 final_status = PublishExportArtifactsAtomically(
1386 artifact_paths.out_path, exported_json, artifact_paths.report_path,
1387 report.dump(2) + "\n");
1388 }
1389 if (!final_status.ok()) {
1390 AddStructuredError(formatter, final_status);
1391 return final_status;
1392 }
1393
1394 formatter.BeginObject("Water Fill Export");
1395 formatter.AddField("out_path", out_path);
1396 formatter.AddField("requested_rooms", requested_rooms);
1397 formatter.AddField("exported_zones", exported_zone_count);
1398 formatter.AddField("status", "success");
1399 formatter.EndObject();
1400 return absl::OkStatus();
1401}
1402
1404 Rom* rom, const resources::ArgumentParser& parser,
1405 resources::OutputFormatter& formatter) {
1406 auto report_path_or = ResolveReportArtifactPath(parser, rom);
1407 if (!report_path_or.ok()) {
1408 AddStructuredError(formatter, report_path_or.status());
1409 return report_path_or.status();
1410 }
1411 const std::optional<std::filesystem::path> report_path = *report_path_or;
1412
1413 const bool dry_run = parser.HasFlag("dry-run");
1414 const bool strict_masks = parser.HasFlag("strict-masks");
1415 json report = BuildBaseReport(GetName(), dry_run);
1416 const absl::Status status = [&]() -> absl::Status {
1417 const std::string in_path = parser.GetString("in").value();
1418 report["in_path"] = in_path;
1419 report["strict_masks"] = strict_masks;
1420
1421 if (!zelda3::HasWaterFillReservedRegion(rom->vector().size())) {
1422 return absl::FailedPreconditionError(
1423 "WaterFill reserved region missing in this ROM");
1424 }
1425
1426 ASSIGN_OR_RETURN(const std::string json_content, ReadTextFile(in_path));
1427 ASSIGN_OR_RETURN(auto zones,
1429 const auto required_collision_rooms =
1430 RequiredCollisionRoomsForImportedWaterFillZones(zones);
1431 if (!required_collision_rooms.empty()) {
1432 json required_rooms_json = json::array();
1433 for (int room_id : required_collision_rooms) {
1434 required_rooms_json.push_back(absl::StrFormat("0x%02X", room_id));
1435 }
1436 report["required_collision_rooms"] = std::move(required_rooms_json);
1437 }
1438
1440 preflight_options.require_water_fill_reserved_region = true;
1441 preflight_options.require_custom_collision_write_support = false;
1442 preflight_options.validate_water_fill_table = true;
1443 preflight_options.validate_custom_collision_maps = true;
1444 preflight_options.room_ids_requiring_custom_collision =
1445 required_collision_rooms;
1446 const auto preflight =
1447 zelda3::RunOracleRomSafetyPreflight(rom, preflight_options);
1448 report["preflight"] = BuildPreflightJson(preflight);
1449 if (!preflight.ok()) {
1450 return preflight.ToStatus();
1451 }
1452
1453 auto original_zones = zones;
1455
1456 int normalized_masks = 0;
1457 std::unordered_map<int, uint8_t> before_masks;
1458 before_masks.reserve(original_zones.size());
1459 for (const auto& z : original_zones) {
1460 before_masks[z.room_id] = z.sram_bit_mask;
1461 }
1462 for (const auto& z : zones) {
1463 auto it = before_masks.find(z.room_id);
1464 if (it == before_masks.end() || it->second != z.sram_bit_mask) {
1465 ++normalized_masks;
1466 }
1467 }
1468
1469 report["zone_count"] = static_cast<int>(zones.size());
1470 report["normalized_masks"] = normalized_masks;
1471
1472 if (strict_masks && normalized_masks > 0) {
1473 return absl::FailedPreconditionError(absl::StrFormat(
1474 "WaterFill masks require normalization (%d changed); rerun without "
1475 "--strict-masks to apply normalized masks",
1476 normalized_masks));
1477 }
1478
1479 if (!dry_run) {
1480 RETURN_IF_ERROR(SerializeAndPersistImport(rom, parser, &report, [&]() {
1481 return zelda3::WriteWaterFillTable(rom, zones);
1482 }));
1483 }
1484
1485 formatter.BeginObject("Water Fill Import");
1486 formatter.AddField("in_path", in_path);
1487 formatter.AddField("mode", dry_run ? "dry-run" : "write");
1488 formatter.AddField("strict_masks", strict_masks);
1489 formatter.AddField("zone_count", static_cast<int>(zones.size()));
1490 formatter.AddField("normalized_masks", normalized_masks);
1491 if (!dry_run) {
1492 formatter.AddField("write_status",
1493 report.value("write_status", std::string("unknown")));
1494 formatter.AddField("save_status",
1495 report.value("save_status", std::string("unknown")));
1496 }
1497 formatter.AddField("status", "success");
1498 formatter.EndObject();
1499 return absl::OkStatus();
1500 }();
1501
1502 return FinalizeWithReport(report_path, std::move(report), status);
1503}
1504
1506 const resources::ArgumentParser& parser) {
1507 return ValidateImportArguments(parser, GetName());
1508}
1509
1511 const resources::ArgumentParser& parser) {
1512 return ValidateExportArguments(parser, GetName());
1513}
1514
1516 const resources::ArgumentParser& parser) {
1517 return ValidateExportArguments(parser, GetName());
1518}
1519
1521 const resources::ArgumentParser& parser) {
1522 return ValidateImportArguments(parser, GetName());
1523}
1524
1525} // namespace handlers
1526} // namespace cli
1527} // namespace yaze
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:28
auto filename() const
Definition rom.h:157
const auto & vector() const
Definition rom.h:155
absl::Status SaveToFile(const SaveSettings &settings)
Definition rom.cc:371
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
absl::Status ExecuteWithContext(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter, const resources::CommandInvocationContext &invocation_context) override
Execute with immutable, invocation-scoped ROM path identity.
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
absl::Status ExecuteWithContext(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter, const resources::CommandInvocationContext &invocation_context) override
Execute with immutable, invocation-scoped ROM path identity.
std::string GetName() const override
Get the command name.
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
std::string GetName() const override
Get the command name.
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
Utility for parsing common CLI argument patterns.
std::optional< std::string > GetString(const std::string &name) const
Parse a named argument (e.g., –format=json or –format json)
bool HasFlag(const std::string &name) const
Check if a flag is present.
absl::Status RequireArgs(const std::vector< std::string > &required) const
Validate that required arguments are present.
Utility for consistent output formatting across commands.
void BeginArray(const std::string &key)
Begin an array.
void BeginObject(const std::string &title="")
Start a JSON object or text section.
void EndObject()
End a JSON object or text section.
void AddField(const std::string &key, const std::string &value)
Add a key-value pair.
void AddHexField(const std::string &key, uint64_t value, int width=2)
Add a hex-formatted field.
ABSL_DECLARE_FLAG(bool, sandbox)
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
std::filesystem::path NextArtifactTempPath(const std::filesystem::path &target_path)
absl::StatusOr< ResolvedExportArtifactPaths > ResolveExportArtifactPaths(const resources::ArgumentParser &parser, const resources::CommandInvocationContext &invocation_context)
absl::Status FinalizeExportWithReport(const ResolvedExportArtifactPaths &artifact_paths, json report, const absl::Status &status)
absl::StatusOr< std::unordered_set< int > > ParseTileFilter(const resources::ArgumentParser &parser)
absl::StatusOr< bool > PathsAlias(const std::filesystem::path &lhs, const std::filesystem::path &rhs)
absl::Status RollBackPublishedArtifacts(std::vector< PendingArtifactPublication > *artifacts, const absl::Status &publication_failure)
void AddStructuredError(resources::OutputFormatter &formatter, const absl::Status &status)
absl::Status ValidateImportArguments(const resources::ArgumentParser &parser, absl::string_view command_name)
absl::Status PublishResolvedTextFileAtomically(const std::filesystem::path &target_path, absl::string_view content)
absl::Status RejectArtifactSetAliasesAfterPublication(const std::vector< PendingArtifactPublication > &artifacts)
absl::StatusOr< std::optional< std::filesystem::path > > ResolveReportArtifactPath(const resources::ArgumentParser &parser, const Rom *rom)
void CleanupPendingArtifactFiles(const std::vector< PendingArtifactPublication > &artifacts)
absl::StatusOr< std::optional< std::filesystem::path > > CreateArtifactRollbackCopy(const std::filesystem::path &target_path)
absl::StatusOr< std::filesystem::path > ResolveStableArtifactPath(const std::filesystem::path &path)
absl::StatusOr< std::filesystem::path > WriteExclusiveArtifactTemp(const std::filesystem::path &target_path, absl::string_view content)
absl::Status FinalizeWithReport(const std::optional< std::filesystem::path > &resolved_report_path, json report, const absl::Status &status)
absl::Status ReplaceArtifactFromTemp(const std::filesystem::path &temp_path, const std::filesystem::path &target_path)
absl::Status PublishExportArtifactsAtomically(const std::filesystem::path &out_path, absl::string_view out_content, const std::optional< std::filesystem::path > &report_path, absl::string_view report_content)
std::vector< int > RequiredCollisionRoomsForImportedWaterFillZones(const std::vector< zelda3::WaterFillZoneEntry > &zones)
absl::Status PublishArtifactSetAtomically(std::vector< PendingArtifactPublication > artifacts)
absl::Status SerializeAndPersistImport(Rom *rom, const resources::ArgumentParser &parser, json *report, Serializer &&serializer)
absl::Status RejectArtifactRomAliases(absl::string_view option_name, const std::filesystem::path &artifact_path, const resources::CommandInvocationContext &invocation_context)
absl::StatusOr< std::filesystem::path > NormalizedAbsolutePath(const std::filesystem::path &path)
json BuildPreflightJson(const zelda3::OracleRomSafetyPreflightResult &preflight)
absl::Status ValidatePublishTarget(const std::filesystem::path &target_path)
absl::Status ValidateExportArguments(const resources::ArgumentParser &parser, absl::string_view command_name)
absl::StatusOr< std::vector< int > > ParseRoomSelection(const resources::ArgumentParser &parser)
bool ParseHexString(absl::string_view str, int *out)
Definition hex_util.h:17
absl::StatusOr< std::string > DumpWaterFillZonesToJsonString(const std::vector< WaterFillZoneEntry > &zones)
absl::Status NormalizeWaterFillZoneMasks(std::vector< WaterFillZoneEntry > *zones)
absl::StatusOr< std::vector< CustomCollisionRoomEntry > > LoadCustomCollisionRoomsFromJsonString(const std::string &json_content)
absl::StatusOr< std::string > DumpCustomCollisionRoomsToJsonString(const std::vector< CustomCollisionRoomEntry > &rooms)
absl::StatusOr< std::vector< WaterFillZoneEntry > > LoadWaterFillZonesFromJsonString(const std::string &json_content)
OracleRomSafetyPreflightResult RunOracleRomSafetyPreflight(Rom *rom, const OracleRomSafetyPreflightOptions &options)
absl::StatusOr< CustomCollisionMap > LoadCustomCollisionMap(Rom *rom, int room_id)
constexpr bool HasWaterFillReservedRegion(std::size_t rom_size)
constexpr int kNumberOfRooms
absl::Status SaveAllCollision(Rom *rom, absl::Span< Room > rooms)
Definition room.cc:3413
constexpr bool HasCustomCollisionWriteSupport(std::size_t rom_size)
absl::StatusOr< std::vector< WaterFillZoneEntry > > LoadWaterFillTable(Rom *rom)
absl::Status WriteWaterFillTable(Rom *rom, const std::vector< WaterFillZoneEntry > &zones)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::optional< std::filesystem::path > active_rom_path
std::optional< std::filesystem::path > source_rom_path
std::vector< CustomCollisionTileEntry > tiles