yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
oracle_smoke_check_commands.cc
Go to the documentation of this file.
2
3#include <filesystem>
4#include <fstream>
5#include <ios>
6#include <string>
7#include <vector>
8
9#include "absl/status/status.h"
10#include "absl/strings/str_format.h"
12#include "nlohmann/json.hpp"
13#include "rom/rom.h"
14#include "util/macro.h"
17
18namespace yaze::cli::handlers {
19
20using json = nlohmann::json;
21
23 const {
24 Descriptor desc;
25 desc.display_name = "Oracle Smoke Check";
26 desc.summary =
27 "Single-shot Oracle ROM smoke check covering D4 Zora Temple (water "
28 "system), D6 Goron Mines (minecart rooms 0xA8/0xB8/0xD8/0xDA), and "
29 "D3 Kalyxo Castle (prison room 0x32 collision readiness). Structural "
30 "failures always exit non-zero; readiness gaps are informational unless "
31 "--strict-readiness is set.";
32 desc.todo_reference = "todo#oracle-testing-infra";
33 desc.entries = {
34 {"--strict-readiness",
35 "Also fail when D4 rooms 0x25/0x27 or D3 room 0x32 lack authored "
36 "custom collision data",
37 ""},
38 {"--min-d6-track-rooms",
39 "Fail if fewer than N of the 4 D6 rooms have track rail objects "
40 "(default 0 = informational only). Treated as structural failure.",
41 ""},
42 {"--report", "Write full JSON summary to this path in addition to stdout",
43 ""},
44 };
45 return desc;
46}
47
49 const resources::ArgumentParser& parser) {
50 // Validate --min-d6-track-rooms if provided.
51 if (parser.GetString("min-d6-track-rooms").has_value()) {
52 auto int_or = parser.GetInt("min-d6-track-rooms");
53 if (!int_or.ok()) {
54 return absl::InvalidArgumentError(
55 "--min-d6-track-rooms: value must be a non-negative integer");
56 }
57 if (*int_or < 0) {
58 return absl::InvalidArgumentError(
59 "--min-d6-track-rooms: value must be >= 0");
60 }
61 }
62
63 // Probe --report path before the formatter opens. Failure here emits nothing
64 // to stdout (only stderr + non-zero exit), preserving the contract that
65 // stdout is valid iff exit code is 0.
66 if (auto rp = parser.GetString("report"); rp.has_value() && !rp->empty()) {
67 const std::filesystem::path rp_path(*rp);
68 std::error_code ec;
69 const bool existed_before = std::filesystem::exists(rp_path, ec);
70 std::ofstream probe(*rp, std::ios::out | std::ios::binary | std::ios::app);
71 if (!probe.is_open()) {
72 return absl::PermissionDeniedError(
73 absl::StrFormat("oracle-smoke-check: cannot open report file "
74 "for writing: %s",
75 *rp));
76 }
77 probe.close();
78 if (!existed_before) {
79 std::filesystem::remove(rp_path, ec);
80 }
81 }
82 return absl::OkStatus();
83}
84
86 Rom* rom, const resources::ArgumentParser& parser,
87 resources::OutputFormatter& formatter) {
88 const bool strict_readiness = parser.HasFlag("strict-readiness");
89
90 // Parse --min-d6-track-rooms (default 0 = informational only).
91 int min_d6_track_rooms = 0;
92 if (auto int_or = parser.GetInt("min-d6-track-rooms"); int_or.ok()) {
93 min_d6_track_rooms = *int_or;
94 }
95
96 // ------------------------------------------------------------------
97 // D4 Zora Temple — structural preflight
98 // ------------------------------------------------------------------
99 // Validates the water-fill reserved region/table and requires runtime table
100 // membership for D4 rooms. Authored room collision is a readiness check.
102 structural_opts.require_water_fill_reserved_region = true;
103 structural_opts.require_custom_collision_write_support = false;
104 structural_opts.validate_water_fill_table = true;
105 structural_opts.validate_custom_collision_maps = false;
106 structural_opts.room_ids_requiring_water_fill_zones = {0x25, 0x27};
107 const auto structural =
108 zelda3::RunOracleRomSafetyPreflight(rom, structural_opts);
109 const bool d4_structural_ok = structural.ok();
110 bool d4_water_fill_rooms_ok = true;
111 for (const auto& error : structural.errors) {
112 if (error.code == "ORACLE_REQUIRED_WATER_FILL_ROOM_MISSING" ||
113 error.code == "ORACLE_REQUIRED_WATER_FILL_ROOM_OUT_OF_RANGE" ||
114 error.code == "ORACLE_WATER_FILL_REGION_MISSING" ||
115 error.code == "ORACLE_WATER_FILL_HEADER_CORRUPT" ||
116 error.code == "ORACLE_WATER_FILL_TABLE_INVALID") {
117 d4_water_fill_rooms_ok = false;
118 break;
119 }
120 }
121
122 // Whether the ROM's custom-collision write region exists determines if
123 // required-room checks can actually run. Mirror the preflight library's
124 // gate (HasCustomCollisionWriteSupport) so we report "ran" vs "skipped"
125 // rather than silently claiming readiness=true on non-expanded ROMs.
126 const bool collision_write_support =
128
129 // ------------------------------------------------------------------
130 // D4 Zora Temple — room collision readiness (informational by default)
131 // ------------------------------------------------------------------
132 bool d4_required_ok = false;
133 const char* d4_readiness_state = "skipped";
134 if (collision_write_support) {
137 d4_opts.validate_water_fill_table = false;
138 d4_opts.validate_custom_collision_maps = false;
139 d4_opts.room_ids_requiring_custom_collision = {0x25, 0x27};
140 const auto d4_required = zelda3::RunOracleRomSafetyPreflight(rom, d4_opts);
141 d4_required_ok = d4_required.ok();
142 d4_readiness_state = "ran";
143 }
144
145 // ------------------------------------------------------------------
146 // D6 Goron Mines — minecart audit on fixed room set
147 // ------------------------------------------------------------------
148 // The audit always exits ok (issues are informational). d6_ok can only be
149 // false if the ROM itself is unreadable (which would have been caught by the
150 // structural check above).
151 DungeonMinecartAuditCommandHandler minecart_handler;
152 std::string d6_out;
153 const bool d6_ok = minecart_handler
154 .Run({"--rooms=0xA8,0xB8,0xD8,0xDA",
155 "--include-track-objects", "--format=json"},
156 rom, &d6_out)
157 .ok();
158
159 // Count D6 rooms with non-empty track_object_subtypes from audit output.
160 int track_rooms_found = 0;
161 {
162 const auto d6_doc = json::parse(d6_out, nullptr, false);
163 if (!d6_doc.is_discarded() && d6_doc.contains("Dungeon Minecart Audit")) {
164 for (const auto& room :
165 d6_doc["Dungeon Minecart Audit"].value("rooms", json::array())) {
166 if (!room.value("track_object_subtypes", json::array()).empty()) {
167 ++track_rooms_found;
168 }
169 }
170 }
171 }
172 const bool meets_min_track_rooms =
173 (min_d6_track_rooms == 0 || track_rooms_found >= min_d6_track_rooms);
174
175 // ------------------------------------------------------------------
176 // D3 Kalyxo Castle — prison room collision readiness
177 // ------------------------------------------------------------------
178 bool d3_ok = false;
179 const char* d3_readiness_state = "skipped";
180 if (collision_write_support) {
183 d3_opts.validate_water_fill_table = false;
184 d3_opts.validate_custom_collision_maps = false;
186 const auto d3_required = zelda3::RunOracleRomSafetyPreflight(rom, d3_opts);
187 d3_ok = d3_required.ok();
188 d3_readiness_state = "ran";
189 }
190
191 // ------------------------------------------------------------------
192 // Aggregate
193 // ------------------------------------------------------------------
194 // meets_min_track_rooms is structural (not readiness) so it's always gated.
195 bool overall_ok = d4_structural_ok && d6_ok && meets_min_track_rooms;
196 if (strict_readiness) {
197 // Only apply readiness gates if the checks actually ran. A "skipped" check
198 // on a non-expanded ROM is not a readiness failure — it's a precondition
199 // failure, already surfaced by d4_structural_ok = false.
200 if (collision_write_support) {
201 overall_ok = overall_ok && d4_required_ok && d3_ok;
202 }
203 }
204
205 // ------------------------------------------------------------------
206 // Build parallel JSON report (for --report file)
207 // ------------------------------------------------------------------
208 json report;
209 report["ok"] = overall_ok;
210 report["status"] = overall_ok ? "pass" : "fail";
211 report["strict_readiness"] = strict_readiness;
212
213 json d4_json = json::object();
214 d4_json["structural_ok"] = d4_structural_ok;
215 d4_json["water_fill_rooms_ok"] = d4_water_fill_rooms_ok;
216 d4_json["required_water_fill_rooms"] = json::array({"0x25", "0x27"});
217 d4_json["required_rooms_check"] = d4_readiness_state;
218 if (collision_write_support) {
219 d4_json["required_rooms_ok"] = d4_required_ok;
220 }
221 d4_json["required_rooms"] = json::array({"0x25", "0x27"});
222 json structural_errors = json::array();
223 for (const auto& err : structural.errors) {
224 structural_errors.push_back({{"code", err.code}, {"message", err.message}});
225 }
226 d4_json["structural_errors"] = std::move(structural_errors);
227
228 json d6_json = json::object();
229 d6_json["ok"] = d6_ok;
230 d6_json["rooms"] = json::array({"0xA8", "0xB8", "0xD8", "0xDA"});
231 d6_json["track_rooms_found"] = track_rooms_found;
232 d6_json["min_track_rooms"] = min_d6_track_rooms;
233 d6_json["meets_min_track_rooms"] = meets_min_track_rooms;
234
235 json d3_json = json::object();
236 d3_json["readiness_check"] = d3_readiness_state;
237 if (collision_write_support) {
238 d3_json["ok"] = d3_ok;
239 }
240 d3_json["required_rooms"] = json::array({"0x32"});
241
242 report["checks"] = json::object();
243 report["checks"]["d4_zora_temple"] = std::move(d4_json);
244 report["checks"]["d6_goron_mines"] = std::move(d6_json);
245 report["checks"]["d3_kalyxo_castle"] = std::move(d3_json);
246
247 // ------------------------------------------------------------------
248 // Emit to formatter
249 // ------------------------------------------------------------------
250 formatter.BeginObject("Oracle Smoke Check");
251 formatter.AddField("ok", overall_ok);
252 formatter.AddField("status",
253 overall_ok ? std::string("pass") : std::string("fail"));
254 formatter.AddField("strict_readiness", strict_readiness);
255
256 formatter.BeginObject("checks");
257
258 formatter.BeginObject("d4_zora_temple");
259 formatter.AddField("structural_ok", d4_structural_ok);
260 formatter.AddField("water_fill_rooms_ok", d4_water_fill_rooms_ok);
261 formatter.AddField("required_rooms_check", std::string(d4_readiness_state));
262 if (collision_write_support) {
263 formatter.AddField("required_rooms_ok", d4_required_ok);
264 }
265 formatter.EndObject();
266
267 formatter.BeginObject("d6_goron_mines");
268 formatter.AddField("ok", d6_ok);
269 formatter.AddField("track_rooms_found", track_rooms_found);
270 formatter.AddField("min_track_rooms", min_d6_track_rooms);
271 formatter.AddField("meets_min_track_rooms", meets_min_track_rooms);
272 formatter.EndObject();
273
274 formatter.BeginObject("d3_kalyxo_castle");
275 formatter.AddField("readiness_check", std::string(d3_readiness_state));
276 if (collision_write_support) {
277 formatter.AddField("ok", d3_ok);
278 }
279 formatter.EndObject();
280
281 formatter.EndObject(); // close "checks"
282 formatter.EndObject(); // close "Oracle Smoke Check"
283
284 // ------------------------------------------------------------------
285 // Write report file (always written, pass or fail)
286 // ------------------------------------------------------------------
287 if (auto rp = parser.GetString("report"); rp.has_value() && !rp->empty()) {
288 std::ofstream report_file(
289 *rp, std::ios::out | std::ios::binary | std::ios::trunc);
290 if (!report_file.is_open()) {
291 return absl::PermissionDeniedError(absl::StrFormat(
292 "oracle-smoke-check: cannot open report file: %s", *rp));
293 }
294 report_file << report.dump(2) << "\n";
295 report_file.close();
296 if (report_file.fail()) {
297 return absl::InternalError(absl::StrFormat(
298 "oracle-smoke-check: failed writing report: %s", *rp));
299 }
300 }
301
302 if (!overall_ok) {
303 // Attribute the failure accurately: structural (d4/d6/threshold always
304 // evaluated) vs readiness-only (only possible in strict mode after
305 // structural passed).
306 const bool structural_failed =
307 !d4_structural_ok || !d6_ok || !meets_min_track_rooms;
308 return absl::FailedPreconditionError(absl::StrFormat(
309 "oracle-smoke-check failed %s",
310 structural_failed ? "(structural)" : "(strict-readiness)"));
311 }
312 return absl::OkStatus();
313}
314
315} // namespace yaze::cli::handlers
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
const auto & vector() const
Definition rom.h:155
Descriptor Describe() const override
Provide metadata for TUI/help summaries.
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.
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::StatusOr< int > GetInt(const std::string &name) const
Parse an integer argument (supports hex with 0x prefix)
absl::Status Run(const std::vector< std::string > &args, Rom *rom_context, std::string *captured_output=nullptr)
Execute the command.
Utility for consistent output formatting across commands.
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.
OracleRomSafetyPreflightResult RunOracleRomSafetyPreflight(Rom *rom, const OracleRomSafetyPreflightOptions &options)
constexpr bool HasCustomCollisionWriteSupport(std::size_t rom_size)