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