yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
oracle_rom_safety_preflight.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <cctype>
6#include <cstdio>
7#include <cstdlib>
8#include <fstream>
9#include <sstream>
10#include <string>
11#include <vector>
12
13#ifdef __APPLE__
14#include <CommonCrypto/CommonDigest.h>
15#endif
16
17#include "absl/strings/str_format.h"
18#include "rom/rom.h"
22
23namespace yaze::zelda3 {
24
25namespace {
26
27// WaterFillTable_Generated stores each room ID in a single byte even though
28// the dungeon editor supports room IDs through 0x127.
29constexpr int kMaxWaterFillRuntimeRoomId = 0xFF;
30
31void AddError(OracleRomSafetyPreflightResult* result, std::string code,
32 std::string message, absl::StatusCode status_code,
33 int room_id = -1) {
34 if (!result) {
35 return;
36 }
38 issue.code = std::move(code);
39 issue.message = std::move(message);
40 issue.status_code = status_code;
41 issue.room_id = room_id;
42 result->errors.push_back(std::move(issue));
43}
44
45} // namespace
46
48 if (errors.empty()) {
49 return absl::OkStatus();
50 }
51 const auto& first = errors.front();
52 if (errors.size() == 1) {
53 return absl::Status(first.status_code, first.message);
54 }
55 return absl::Status(first.status_code,
56 absl::StrFormat("%s (plus %d additional safety issue(s))",
57 first.message, errors.size() - 1));
58}
59
61 Rom* rom, const OracleRomSafetyPreflightOptions& options) {
63
64 if (!rom || !rom->is_loaded()) {
65 AddError(&result, "ORACLE_ROM_NOT_LOADED", "ROM not loaded",
66 absl::StatusCode::kInvalidArgument);
67 return result;
68 }
69
70 for (int room_id : options.room_ids_requiring_water_fill_zones) {
71 if (room_id < 0 || room_id > kMaxWaterFillRuntimeRoomId) {
72 AddError(&result, "ORACLE_REQUIRED_WATER_FILL_ROOM_OUT_OF_RANGE",
73 absl::StrFormat(
74 "Required WaterFill room 0x%02X is out of valid range "
75 "(0..0x%02X)",
76 room_id, kMaxWaterFillRuntimeRoomId),
77 absl::StatusCode::kInvalidArgument, room_id);
78 }
79 }
80 if (!result.ok()) {
81 return result;
82 }
83
84 const std::size_t rom_size = rom->vector().size();
85
87 !HasWaterFillReservedRegion(rom_size)) {
88 AddError(&result, "ORACLE_WATER_FILL_REGION_MISSING",
89 "WaterFill reserved region not present in this ROM",
90 absl::StatusCode::kFailedPrecondition);
91 }
92
95 AddError(&result, "ORACLE_COLLISION_WRITE_REGION_MISSING",
96 "Custom collision write support not present in this ROM",
97 absl::StatusCode::kFailedPrecondition);
98 }
99
100 std::vector<WaterFillZoneEntry> water_fill_zones;
101 bool water_fill_table_load_attempted = false;
102 bool water_fill_table_loaded = false;
103 auto load_water_fill_table = [&]() {
104 if (water_fill_table_load_attempted) {
105 return;
106 }
107 water_fill_table_load_attempted = true;
108 auto zones_or = LoadWaterFillTable(rom);
109 if (!zones_or.ok()) {
110 AddError(&result, "ORACLE_WATER_FILL_TABLE_INVALID",
111 std::string(zones_or.status().message()),
112 zones_or.status().code());
113 return;
114 }
115 water_fill_zones = std::move(*zones_or);
116 water_fill_table_loaded = true;
117 };
118
119 if (options.validate_water_fill_table &&
120 HasWaterFillReservedRegion(rom_size)) {
121 const auto& data = rom->vector();
122 const uint8_t zone_count =
123 data[static_cast<std::size_t>(kWaterFillTableStart)];
124 if (zone_count > 8) {
125 AddError(
126 &result, "ORACLE_WATER_FILL_HEADER_CORRUPT",
127 absl::StrFormat("WaterFill table header corrupted (zone_count=%u)",
128 zone_count),
129 absl::StatusCode::kFailedPrecondition);
130 }
131
132 load_water_fill_table();
133 }
134
135 // Check the runtime WaterFill table membership separately from custom
136 // collision. A room may have valid authored collision while still being
137 // absent from the table that WaterFill_FindRoomInTable actually scans.
138 if (!options.room_ids_requiring_water_fill_zones.empty()) {
139 load_water_fill_table();
140 for (int room_id : options.room_ids_requiring_water_fill_zones) {
141 if (!water_fill_table_loaded) {
142 continue;
143 }
144 const bool found =
145 std::any_of(water_fill_zones.begin(), water_fill_zones.end(),
146 [room_id](const WaterFillZoneEntry& zone) {
147 return zone.room_id == room_id;
148 });
149 if (!found) {
150 AddError(&result, "ORACLE_REQUIRED_WATER_FILL_ROOM_MISSING",
151 absl::StrFormat(
152 "Required room 0x%02X is missing from the WaterFill table",
153 room_id),
154 absl::StatusCode::kFailedPrecondition, room_id);
155 }
156 }
157 }
158
159 if (options.validate_custom_collision_maps &&
161 const int max_errors = std::max(1, options.max_collision_errors);
162 int collision_errors = 0;
163 for (int room_id = 0; room_id < kNumberOfRooms; ++room_id) {
164 auto map_or = LoadCustomCollisionMap(rom, room_id);
165 if (map_or.ok()) {
166 continue;
167 }
168 AddError(&result, "ORACLE_COLLISION_POINTER_INVALID",
169 absl::StrFormat("Room 0x%02X: %s", room_id,
170 std::string(map_or.status().message()).c_str()),
171 map_or.status().code(), room_id);
172 ++collision_errors;
173 if (collision_errors >= max_errors) {
174 AddError(&result, "ORACLE_COLLISION_POINTER_INVALID_TRUNCATED",
175 absl::StrFormat(
176 "Collision pointer validation stopped after %d "
177 "errors (increase max_collision_errors to scan more)",
178 max_errors),
179 absl::StatusCode::kFailedPrecondition);
180 break;
181 }
182 }
183 }
184
185 // Check that game-mechanic-critical rooms have authored custom collision data.
186 // This is independent of the pointer-validity sweep above: a room can have a
187 // valid (null) pointer that loads successfully with has_data=false, meaning
188 // the room has not been authored. For rooms like the D3 prison escape room
189 // (which requires MinishSwitch collision geometry to function), missing data
190 // is a gameplay-blocking error.
191 if (!options.room_ids_requiring_custom_collision.empty() &&
193 for (int room_id : options.room_ids_requiring_custom_collision) {
194 if (room_id < 0 || room_id >= kNumberOfRooms) {
195 AddError(&result, "ORACLE_REQUIRED_ROOM_OUT_OF_RANGE",
196 absl::StrFormat(
197 "Required room 0x%02X is out of valid range (0..0x%02X)",
198 room_id, kNumberOfRooms - 1),
199 absl::StatusCode::kInvalidArgument);
200 continue;
201 }
202 auto map_or = LoadCustomCollisionMap(rom, room_id);
203 if (!map_or.ok()) {
204 AddError(&result, "ORACLE_REQUIRED_ROOM_MISSING_COLLISION",
205 absl::StrFormat(
206 "Required room 0x%02X: collision pointer invalid: %s",
207 room_id, std::string(map_or.status().message()).c_str()),
208 map_or.status().code(), room_id);
209 continue;
210 }
211 if (!map_or->has_data) {
212 AddError(
213 &result, "ORACLE_REQUIRED_ROOM_MISSING_COLLISION",
214 absl::StrFormat("Required room 0x%02X has no custom collision data "
215 "(room not authored)",
216 room_id),
217 absl::StatusCode::kFailedPrecondition, room_id);
218 }
219 }
220 }
221
222 return result;
223}
224
225namespace {
226
227// Convert a raw digest to a lowercase hex string.
228std::string DigestToHex(const unsigned char* digest, std::size_t len) {
229 std::string hex;
230 hex.reserve(len * 2);
231 for (std::size_t i = 0; i < len; ++i) {
232 hex += absl::StrFormat("%02x", digest[i]);
233 }
234 return hex;
235}
236
237std::string ExtractHexDigestLine(const std::string& output) {
238 std::istringstream lines(output);
239 std::string line;
240 while (std::getline(lines, line)) {
241 // sha256sum/shasum output: "<hash> <filename>"
242 // certutil output: hash on its own line
243 // Extract the first whitespace-delimited token from each line.
244 std::istringstream tokens(line);
245 std::string token;
246 if (!(tokens >> token))
247 continue;
248
249 // Validate: must be exactly 64 lowercase hex chars.
250 if (token.size() != 64)
251 continue;
252 bool valid = true;
253 for (unsigned char c : token) {
254 if (!std::isxdigit(c)) {
255 valid = false;
256 break;
257 }
258 }
259 if (!valid)
260 continue;
261
262 // Normalize to lowercase.
263 std::string hash;
264 hash.reserve(64);
265 for (unsigned char c : token) {
266 hash.push_back(static_cast<char>(std::tolower(c)));
267 }
268 return hash;
269 }
270 return {};
271}
272
273} // namespace
274
275absl::StatusOr<std::string> ComputeSha256(const std::string& file_path) {
276 std::ifstream file(file_path, std::ios::binary);
277 if (!file.is_open()) {
278 return absl::NotFoundError(
279 absl::StrFormat("Cannot open file for hashing: %s", file_path));
280 }
281
282#ifdef __APPLE__
283 // Use CommonCrypto on macOS (part of libSystem, no extra linking needed).
284 CC_SHA256_CTX ctx;
285 CC_SHA256_Init(&ctx);
286
287 std::array<char, 8192> buffer;
288 while (file.read(buffer.data(), buffer.size()) || file.gcount() > 0) {
289 CC_SHA256_Update(&ctx, buffer.data(), static_cast<CC_LONG>(file.gcount()));
290 }
291
292 unsigned char digest[CC_SHA256_DIGEST_LENGTH];
293 CC_SHA256_Final(digest, &ctx);
294 return DigestToHex(digest, CC_SHA256_DIGEST_LENGTH);
295#else
296 // Fallback: shell out to platform hash utilities.
297 file.close();
298
299 std::string command;
300#ifdef _WIN32
301 command = absl::StrFormat("certutil -hashfile \"%s\" SHA256", file_path);
302#else
303 // Try sha256sum first (Linux), then shasum -a 256 (BSD/macOS shell envs).
304 if (std::system("command -v sha256sum >/dev/null 2>&1") == 0) {
305 command = absl::StrFormat("sha256sum '%s'", file_path);
306 } else if (std::system("command -v shasum >/dev/null 2>&1") == 0) {
307 command = absl::StrFormat("shasum -a 256 '%s'", file_path);
308 } else {
309 return absl::UnavailableError(
310 "Neither sha256sum nor shasum found on this system");
311 }
312#endif
313
314#ifdef _WIN32
315 FILE* pipe = _popen(command.c_str(), "r");
316#else
317 FILE* pipe = popen(command.c_str(), "r");
318#endif
319 if (!pipe) {
320 return absl::InternalError("Failed to execute hash command");
321 }
322
323 std::array<char, 128> result_buf;
324 std::string output;
325 while (fgets(result_buf.data(), result_buf.size(), pipe) != nullptr) {
326 output += result_buf.data();
327 }
328
329#ifdef _WIN32
330 int status = _pclose(pipe);
331#else
332 int status = pclose(pipe);
333#endif
334 if (status != 0) {
335 return absl::InternalError(
336 absl::StrFormat("Hash command failed with status %d", status));
337 }
338
339 const std::string hash = ExtractHexDigestLine(output);
340 if (hash.empty()) {
341 return absl::InternalError("Unexpected hash command output format");
342 }
343 return hash;
344#endif
345}
346
347absl::Status VerifySha256(const std::string& file_path,
348 const std::string& expected_hash) {
349 auto actual_hash_or = ComputeSha256(file_path);
350 if (!actual_hash_or.ok()) {
351 return actual_hash_or.status();
352 }
353
354 const std::string& actual_hash = *actual_hash_or;
355 if (actual_hash != expected_hash) {
356 return absl::DataLossError(
357 absl::StrFormat("SHA-256 mismatch for %s: expected %s, got %s",
358 file_path, expected_hash, actual_hash));
359 }
360
361 return absl::OkStatus();
362}
363
364} // namespace yaze::zelda3
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
bool is_loaded() const
Definition rom.h:144
std::string DigestToHex(const unsigned char *digest, std::size_t len)
void AddError(OracleRomSafetyPreflightResult *result, std::string code, std::string message, absl::StatusCode status_code, int room_id=-1)
Zelda 3 specific classes and functions.
absl::Status VerifySha256(const std::string &file_path, const std::string &expected_hash)
constexpr int kWaterFillTableStart
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::StatusOr< std::string > ComputeSha256(const std::string &file_path)
constexpr bool HasCustomCollisionWriteSupport(std::size_t rom_size)
absl::StatusOr< std::vector< WaterFillZoneEntry > > LoadWaterFillTable(Rom *rom)