yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
game_parity_gate.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <fstream>
5#include <iterator>
6#include <set>
7#include <sstream>
8#include <tuple>
9
10#include "absl/strings/str_cat.h"
11#include "absl/strings/str_format.h"
12#include "absl/strings/str_join.h"
13#include "nlohmann/json.hpp"
14#include "util/rom_hash.h"
16
18
19namespace {
20
21using json = nlohmann::json;
22using TileKey = std::tuple<int, int, int, int>; // room, layer, x, y
23
24constexpr int kBaselineVersion = 1;
25
26std::string RoomKey(int room_id) {
27 return absl::StrFormat("0x%03X", room_id);
28}
29
30bool ReadFileBytes(const std::filesystem::path& path,
31 std::vector<uint8_t>& out) {
32 std::ifstream in(path, std::ios::binary);
33 if (!in) {
34 return false;
35 }
36 out.assign(std::istreambuf_iterator<char>(in), {});
37 return true;
38}
39
40std::string ManifestString(const json& manifest, const char* key) {
41 if (!manifest.contains(key) || !manifest[key].is_string()) {
42 return std::string();
43 }
44 return manifest[key].get<std::string>();
45}
46
47// Room ids are written as "0x0A4" in manifests and baselines.
48bool ParseRoomId(std::string_view text, int& room_id) {
49 if (text.size() < 3 || text[0] != '0' || (text[1] != 'x' && text[1] != 'X')) {
50 return false;
51 }
52 int value = 0;
53 for (char ch : text.substr(2)) {
54 int digit = -1;
55 if (ch >= '0' && ch <= '9')
56 digit = ch - '0';
57 if (ch >= 'a' && ch <= 'f')
58 digit = ch - 'a' + 10;
59 if (ch >= 'A' && ch <= 'F')
60 digit = ch - 'A' + 10;
61 if (digit < 0 || value > 0xFFF) {
62 return false;
63 }
64 value = value * 16 + digit;
65 }
66 room_id = value;
67 return true;
68}
69
70} // namespace
71
72std::string CaptureRomSha1(std::span<const uint8_t> rom) {
73 if (rom.size() >= kCaptureRomMinSize) {
74 return util::ComputeSha1Hex(rom.data(), rom.size());
75 }
76 std::vector<uint8_t> padded(kCaptureRomMinSize, 0);
77 std::copy(rom.begin(), rom.end(), padded.begin());
78 return util::ComputeSha1Hex(padded.data(), padded.size());
79}
80
81absl::StatusOr<CaptureSet> LoadVerifiedCaptureSet(
82 const std::filesystem::path& dir, const CaptureExpectation& expected,
83 std::string_view rom_sha1) {
84 std::vector<uint8_t> manifest_bytes;
85 if (!ReadFileBytes(dir / "manifest.json", manifest_bytes)) {
86 return absl::NotFoundError(
87 absl::StrCat("No manifest.json in ", dir.string()));
88 }
89 const json manifest =
90 json::parse(manifest_bytes.begin(), manifest_bytes.end(), nullptr,
91 /*allow_exceptions=*/false);
92 if (manifest.is_discarded() || !manifest.is_object() ||
93 !manifest.contains("rooms") || !manifest["rooms"].is_object()) {
94 return absl::InvalidArgumentError(
95 absl::StrCat(dir.string(), "/manifest.json is malformed"));
96 }
97
98 std::vector<std::string> problems;
99 const std::string manifest_rom = ManifestString(manifest, "rom_sha1");
100 if (manifest_rom != expected.rom_sha1) {
101 problems.push_back(absl::StrCat("capture is for ROM ", manifest_rom,
102 ", baseline expects ", expected.rom_sha1));
103 }
104 if (rom_sha1 != expected.rom_sha1) {
105 // std::string, not the std::string_view: older system abseil (Ubuntu
106 // 22.04) has its own absl::string_view and AlphaNum cannot take std's.
107 problems.push_back(absl::StrCat("ROM under test is ", std::string(rom_sha1),
108 ", baseline expects ", expected.rom_sha1));
109 }
110 const std::string entrance = ManifestString(manifest, "entrance");
111 if (entrance != expected.entrance) {
112 problems.push_back(absl::StrCat("capture entrance is \"", entrance,
113 "\", baseline expects \"",
114 expected.entrance, "\""));
115 }
116 const std::string room_flags = ManifestString(manifest, "room_flags");
117 if (room_flags != expected.room_flags) {
118 problems.push_back(absl::StrCat("capture room_flags is \"", room_flags,
119 "\", baseline expects \"",
120 expected.room_flags, "\""));
121 }
122 if (expected.required_rooms.empty()) {
123 problems.push_back("baseline requires no rooms");
124 }
125
126 CaptureSet set;
127 set.dir = dir;
128 const json& rooms = manifest["rooms"];
129 for (int room_id : expected.required_rooms) {
130 const std::string key = RoomKey(room_id);
131 if (!rooms.contains(key) || !rooms[key].is_object()) {
132 problems.push_back(absl::StrCat("room ", key, ": not in the manifest"));
133 continue;
134 }
135 const json& entry = rooms[key];
136 const std::string status = ManifestString(entry, "status");
137 if (status != "ok") {
138 problems.push_back(
139 absl::StrCat("room ", key, ": status \"", status, "\", not ok"));
140 continue;
141 }
142 const std::string file = ManifestString(entry, "file");
143 const std::string sha1 = ManifestString(entry, "sha1");
144 if (file.empty() || sha1.empty() ||
145 std::filesystem::path(file).filename().string() != file) {
146 problems.push_back(
147 absl::StrCat("room ", key, ": missing or unsafe file/sha1 entry"));
148 continue;
149 }
150 std::vector<uint8_t> bytes;
151 if (!ReadFileBytes(dir / file, bytes)) {
152 problems.push_back(absl::StrCat("room ", key, ": cannot read ", file));
153 continue;
154 }
155 if (bytes.size() != kGameRoomTilemapBytes) {
156 problems.push_back(absl::StrCat("room ", key, ": ", file, " is ",
157 bytes.size(), " bytes, expected ",
159 continue;
160 }
161 if (util::ComputeSha1Hex(bytes.data(), bytes.size()) != sha1) {
162 problems.push_back(absl::StrCat("room ", key, ": ", file,
163 " does not match its manifest SHA-1"));
164 continue;
165 }
166 set.room_tilemaps.emplace(room_id, std::move(bytes));
167 }
168
169 if (!problems.empty()) {
170 return absl::FailedPreconditionError(
171 absl::StrCat("Capture ", dir.string(), " cannot be used:\n ",
172 absl::StrJoin(problems, "\n ")));
173 }
174 return set;
175}
176
177std::string DifferenceDigest(uint16_t game_word, uint16_t yaze_word) {
178 const std::string text = absl::StrFormat("yaze-parity-v1 game=%04X yaze=%04X",
179 game_word, yaze_word);
180 return util::ComputeSha1Hex(reinterpret_cast<const uint8_t*>(text.data()),
181 text.size())
182 .substr(0, 12);
183}
184
185absl::StatusOr<Baseline> ParseBaseline(std::string_view text) {
186 const json root = json::parse(text.begin(), text.end(), nullptr,
187 /*allow_exceptions=*/false);
188 if (root.is_discarded() || !root.is_object()) {
189 return absl::InvalidArgumentError("Baseline is not a JSON object");
190 }
191 Baseline baseline;
192 if (!root.contains("version") || !root["version"].is_number_integer() ||
193 root["version"].get<int>() != kBaselineVersion) {
194 return absl::InvalidArgumentError(
195 absl::StrCat("Baseline version must be ", kBaselineVersion));
196 }
197 baseline.version = kBaselineVersion;
198 baseline.state = ManifestString(root, "state");
199 if (baseline.state.empty()) {
200 return absl::InvalidArgumentError("Baseline needs a \"state\"");
201 }
202 if (!root.contains("capture") || !root["capture"].is_object()) {
203 return absl::InvalidArgumentError("Baseline needs a \"capture\" object");
204 }
205 const json& capture = root["capture"];
206 baseline.capture.rom_sha1 = ManifestString(capture, "rom_sha1");
207 baseline.capture.entrance = ManifestString(capture, "entrance");
208 baseline.capture.room_flags = ManifestString(capture, "room_flags");
209 if (baseline.capture.rom_sha1.size() != 40 ||
210 baseline.capture.entrance.empty()) {
211 return absl::InvalidArgumentError(
212 "Baseline capture needs a 40-digit rom_sha1 and an entrance");
213 }
214 if (!capture.contains("required_rooms") ||
215 !capture["required_rooms"].is_array()) {
216 return absl::InvalidArgumentError(
217 "Baseline capture needs a required_rooms array");
218 }
219 for (const auto& room : capture["required_rooms"]) {
220 int room_id = -1;
221 if (!room.is_string() || !ParseRoomId(room.get<std::string>(), room_id)) {
222 return absl::InvalidArgumentError(
223 "required_rooms entries must look like \"0x0A4\"");
224 }
225 baseline.capture.required_rooms.push_back(room_id);
226 }
227
228 if (!root.contains("groups") || !root["groups"].is_array()) {
229 return absl::InvalidArgumentError("Baseline needs a \"groups\" array");
230 }
231 for (const auto& item : root["groups"]) {
232 BaselineGroup group;
233 if (!item.is_object() || !item.contains("room") ||
234 !item["room"].is_string() ||
235 !ParseRoomId(item["room"].get<std::string>(), group.room_id)) {
236 return absl::InvalidArgumentError(
237 "Each group needs a \"room\" like \"0x0A4\"");
238 }
239 group.reason = ManifestString(item, "reason");
240 group.evidence = ManifestString(item, "evidence");
241 group.reviewed = item.contains("reviewed") &&
242 item["reviewed"].is_boolean() &&
243 item["reviewed"].get<bool>();
244 if (!item.contains("tiles") || !item["tiles"].is_array() ||
245 item["tiles"].empty()) {
246 return absl::InvalidArgumentError(
247 absl::StrCat("Group for room ", RoomKey(group.room_id),
248 " needs a non-empty \"tiles\" array"));
249 }
250 for (const auto& tile_json : item["tiles"]) {
251 BaselineTile tile;
252 if (!tile_json.is_object() || !tile_json.contains("layer") ||
253 !tile_json.contains("x") || !tile_json.contains("y") ||
254 !tile_json["layer"].is_number_integer() ||
255 !tile_json["x"].is_number_integer() ||
256 !tile_json["y"].is_number_integer()) {
257 return absl::InvalidArgumentError(
258 absl::StrCat("Bad tile in room ", RoomKey(group.room_id)));
259 }
260 tile.layer = tile_json["layer"].get<int>();
261 tile.x = tile_json["x"].get<int>();
262 tile.y = tile_json["y"].get<int>();
263 tile.digest = ManifestString(tile_json, "digest");
264 if ((tile.layer != 1 && tile.layer != 2) || tile.x < 0 || tile.x > 63 ||
265 tile.y < 0 || tile.y > 63 || tile.digest.size() != 12) {
266 return absl::InvalidArgumentError(
267 absl::StrCat("Bad tile in room ", RoomKey(group.room_id)));
268 }
269 group.tiles.push_back(tile);
270 }
271 if (std::find(baseline.capture.required_rooms.begin(),
272 baseline.capture.required_rooms.end(),
273 group.room_id) == baseline.capture.required_rooms.end()) {
274 // It would never be checked, so it could never go stale.
275 return absl::InvalidArgumentError(
276 absl::StrCat("Group for room ", RoomKey(group.room_id),
277 " is not in required_rooms"));
278 }
279 baseline.groups.push_back(std::move(group));
280 }
281 return baseline;
282}
283
284std::string SerializeBaseline(const Baseline& baseline) {
285 json root;
286 root["version"] = baseline.version;
287 root["state"] = baseline.state;
288 json capture;
289 capture["rom_sha1"] = baseline.capture.rom_sha1;
290 capture["entrance"] = baseline.capture.entrance;
291 if (!baseline.capture.room_flags.empty()) {
292 capture["room_flags"] = baseline.capture.room_flags;
293 }
294 json rooms = json::array();
295 for (int room_id : baseline.capture.required_rooms) {
296 rooms.push_back(RoomKey(room_id));
297 }
298 capture["required_rooms"] = rooms;
299 root["capture"] = capture;
300 json groups = json::array();
301 for (const auto& group : baseline.groups) {
302 json item;
303 item["room"] = RoomKey(group.room_id);
304 item["reason"] = group.reason;
305 item["evidence"] = group.evidence;
306 item["reviewed"] = group.reviewed;
307 json tiles = json::array();
308 for (const auto& tile : group.tiles) {
309 tiles.push_back({{"layer", tile.layer},
310 {"x", tile.x},
311 {"y", tile.y},
312 {"digest", tile.digest}});
313 }
314 item["tiles"] = tiles;
315 groups.push_back(item);
316 }
317 root["groups"] = groups;
318 return root.dump(2) + "\n";
319}
320
322 switch (kind) {
324 return "new difference";
326 return "changed difference";
328 return "stale baseline entry";
330 return "unreviewed baseline entry";
332 return "duplicate baseline entry";
333 }
334 return "finding";
335}
336
337std::string DescribeFinding(const GateFinding& finding) {
338 return absl::StrFormat(
339 "%s: room %s layer %d (%d,%d)%s%s", GateFindingKindName(finding.kind),
340 RoomKey(finding.room_id), finding.layer, finding.x, finding.y,
341 finding.detail.empty() ? "" : " - ", finding.detail);
342}
343
345 const std::vector<ObservedDifference>& observed,
346 const std::vector<int>& rooms_checked) {
347 GateReport report;
348 const std::set<int> checked(rooms_checked.begin(), rooms_checked.end());
349
350 struct Expected {
351 std::string digest;
352 bool approved = false;
353 std::string reason;
354 };
355 std::map<TileKey, Expected> expected;
356 for (const auto& group : baseline.groups) {
357 if (!checked.contains(group.room_id)) {
358 continue;
359 }
360 const bool approved =
361 group.reviewed && !group.evidence.empty() && !group.reason.empty();
362 for (const auto& tile : group.tiles) {
363 const TileKey key{group.room_id, tile.layer, tile.x, tile.y};
364 if (expected.contains(key)) {
365 report.findings.push_back({GateFinding::Kind::kDuplicate, group.room_id,
366 tile.layer, tile.x, tile.y, group.reason});
367 continue;
368 }
369 expected.emplace(key, Expected{tile.digest, approved, group.reason});
370 if (!approved) {
372 group.room_id, tile.layer, tile.x, tile.y,
373 group.evidence.empty()
374 ? "no evidence recorded"
375 : "reviewed is false"});
376 }
377 }
378 }
379
380 std::set<TileKey> seen;
381 for (const auto& difference : observed) {
382 if (!checked.contains(difference.room_id)) {
383 continue;
384 }
385 const TileKey key{difference.room_id, difference.layer, difference.x,
386 difference.y};
387 seen.insert(key);
388 const std::string digest =
389 DifferenceDigest(difference.game, difference.yaze);
390 auto it = expected.find(key);
391 if (it == expected.end()) {
392 report.findings.push_back(
393 {GateFinding::Kind::kNew, difference.room_id, difference.layer,
394 difference.x, difference.y,
395 absl::StrFormat("game %04X, yaze %04X", difference.game,
396 difference.yaze)});
397 continue;
398 }
399 if (it->second.digest != digest) {
400 report.findings.push_back(
401 {GateFinding::Kind::kChanged, difference.room_id, difference.layer,
402 difference.x, difference.y,
403 absl::StrFormat("baseline %s (%s), now game %04X, yaze %04X",
404 it->second.digest, it->second.reason,
405 difference.game, difference.yaze)});
406 continue;
407 }
408 if (it->second.approved) {
410 }
411 }
412
413 for (const auto& [key, entry] : expected) {
414 if (!seen.contains(key)) {
415 const auto& [room_id, layer, x, y] = key;
416 report.findings.push_back(
417 {GateFinding::Kind::kStale, room_id, layer, x, y,
418 absl::StrCat(entry.reason,
419 ": yaze now matches the game; remove the entry")});
420 }
421 }
422
423 std::sort(report.findings.begin(), report.findings.end(),
424 [](const GateFinding& a, const GateFinding& b) {
425 return std::tie(a.room_id, a.layer, a.y, a.x, a.kind) <
426 std::tie(b.room_id, b.layer, b.y, b.x, b.kind);
427 });
428 return report;
429}
430
431} // namespace yaze::zelda3::parity
auto begin()
Definition rom.h:203
auto end()
Definition rom.h:204
auto data() const
Definition rom.h:201
auto size() const
Definition rom.h:200
std::string ComputeSha1Hex(const uint8_t *data, size_t size)
Compute SHA-1 hash of data, return lowercase hex string (40 chars).
Definition rom_hash.cc:234
bool ReadFileBytes(const std::filesystem::path &path, std::vector< uint8_t > &out)
std::string ManifestString(const json &manifest, const char *key)
std::string SerializeBaseline(const Baseline &baseline)
std::string DifferenceDigest(uint16_t game_word, uint16_t yaze_word)
absl::StatusOr< CaptureSet > LoadVerifiedCaptureSet(const std::filesystem::path &dir, const CaptureExpectation &expected, std::string_view rom_sha1)
GateReport EvaluateParity(const Baseline &baseline, const std::vector< ObservedDifference > &observed, const std::vector< int > &rooms_checked)
const char * GateFindingKindName(GateFinding::Kind kind)
std::string CaptureRomSha1(std::span< const uint8_t > rom)
absl::StatusOr< Baseline > ParseBaseline(std::string_view text)
constexpr size_t kCaptureRomMinSize
std::string DescribeFinding(const GateFinding &finding)
nlohmann::json json
constexpr size_t kGameRoomTilemapBytes
std::vector< BaselineTile > tiles
std::vector< BaselineGroup > groups
std::map< int, std::vector< uint8_t > > room_tilemaps
std::vector< GateFinding > findings