yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
object_coverage_model.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cstdlib>
5#include <fstream>
6#include <set>
7#include <sstream>
8#include <system_error>
9#include <utility>
10
11#include "absl/strings/str_cat.h"
12#include "absl/strings/str_format.h"
13#include "nlohmann/json.hpp"
15
16namespace yaze::editor {
17namespace {
18
19constexpr int kEvidenceFileVersion = 1;
20
21struct IdRange {
22 int first;
23 int last;
24};
25
26// Object IDs per focus group, copied from the family table in
27// docs/internal/plans/dungeon-0.8.0-issue-test-backlog-2026-06-28.md. The
28// order matches ReleaseFocusGroups().
29const std::vector<std::vector<IdRange>>& FocusGroupRanges() {
30 static const std::vector<std::vector<IdRange>> kRanges = {
31 // Strips
32 {{0x33, 0x34}, {0x70, 0x71}, {0x8D, 0x8E}, {0xB2, 0xB4}},
33 // Bars
34 {{0x4C, 0x4C}, {0x8F, 0x8F}, {0xFD6, 0xFD9}},
35 // Water
36 {{0x3F, 0x48}, {0x79, 0x7A}, {0xC8, 0xC9}, {0xD9, 0xD9}, {0xE7, 0xE7}},
37 // Ice and moving floors
38 {{0xCA, 0xCA}, {0xD1, 0xD2}, {0xE3, 0xE6}},
39 // Flood controls
40 {{0xD8, 0xD8}, {0xDA, 0xDA}},
41 // Stairs
42 {{0x12A, 0x12A},
43 {0x12D, 0x133},
44 {0x135, 0x136},
45 {0x138, 0x13B},
46 {0xF9B, 0xFA1},
47 {0xFA6, 0xFA9},
48 {0xFB3, 0xFB3}},
49 // Corners
50 {{0x100, 0x117}},
51 };
52 return kRanges;
53}
54
55std::optional<int> ParseObjectIdKey(const std::string& key) {
56 if (key.size() < 3 || key[0] != '0' || (key[1] != 'x' && key[1] != 'X')) {
57 return std::nullopt;
58 }
59 char* end = nullptr;
60 const long value = std::strtol(key.c_str() + 2, &end, 16);
61 if (end == nullptr || *end != '\0' || value < 0 || value > 0xFFF) {
62 return std::nullopt;
63 }
64 return static_cast<int>(value);
65}
66
67} // namespace
68
70 switch (state) {
72 return "untriaged";
74 return "reproduced";
76 return "fixed-awaiting-proof";
78 return "verified";
80 return "intentional-preview-limit";
81 }
82 return "untriaged";
83}
84
86 switch (state) {
88 return "Not checked";
90 return "Broken";
92 return "Fixed, recheck";
94 return "Matches game";
96 return "Preview limit";
97 }
98 return "Not checked";
99}
100
101std::optional<ObjectEvidenceState> ParseObjectEvidenceState(
102 std::string_view key) {
104 if (key == ObjectEvidenceStateKey(state)) {
105 return state;
106 }
107 }
108 return std::nullopt;
109}
110
111const ObjectEvidence* ObjectEvidenceStore::Find(int object_id) const {
112 auto it = entries_.find(object_id);
113 return it == entries_.end() ? nullptr : &it->second;
114}
115
117 const ObjectEvidence* evidence = Find(object_id);
118 return evidence ? evidence->state : ObjectEvidenceState::kUntriaged;
119}
120
121void ObjectEvidenceStore::Set(int object_id, ObjectEvidence evidence) {
122 if (evidence.state == ObjectEvidenceState::kUntriaged &&
123 evidence.note.empty()) {
124 entries_.erase(object_id);
125 return;
126 }
127 entries_[object_id] = std::move(evidence);
128}
129
130std::string ObjectEvidenceStore::ToJson() const {
131 nlohmann::json objects = nlohmann::json::object();
132 for (const auto& [object_id, evidence] : entries_) {
133 nlohmann::json entry;
134 entry["state"] = ObjectEvidenceStateKey(evidence.state);
135 if (!evidence.note.empty()) {
136 entry["note"] = evidence.note;
137 }
138 if (evidence.room_id >= 0) {
139 entry["room"] = evidence.room_id;
140 }
141 if (!evidence.rom_sha1.empty()) {
142 entry["rom_sha1"] = evidence.rom_sha1;
143 }
144 if (!evidence.updated_utc.empty()) {
145 entry["updated_utc"] = evidence.updated_utc;
146 }
147 objects[FormatObjectId(object_id)] = std::move(entry);
148 }
149 nlohmann::json root;
150 root["version"] = kEvidenceFileVersion;
151 root["objects"] = std::move(objects);
152 if (!capture_dir_.empty()) {
153 root["capture_dir"] = capture_dir_;
154 }
155 return root.dump(2) + "\n";
156}
157
158absl::StatusOr<ObjectEvidenceStore> ObjectEvidenceStore::FromJson(
159 std::string_view json) {
160 nlohmann::json root = nlohmann::json::parse(json, nullptr,
161 /*allow_exceptions=*/false);
162 if (root.is_discarded() || !root.is_object()) {
163 return absl::InvalidArgumentError("Object evidence file is not JSON");
164 }
165 if (!root.contains("version") || !root["version"].is_number_integer() ||
166 root["version"].get<int>() != kEvidenceFileVersion) {
167 return absl::InvalidArgumentError(absl::StrCat(
168 "Object evidence file version must be ", kEvidenceFileVersion));
169 }
171 if (root.contains("capture_dir") && root["capture_dir"].is_string()) {
172 store.capture_dir_ = root["capture_dir"].get<std::string>();
173 }
174 if (!root.contains("objects")) {
175 return store;
176 }
177 const auto& objects = root["objects"];
178 if (!objects.is_object()) {
179 return absl::InvalidArgumentError("\"objects\" must be a JSON object");
180 }
181 for (const auto& [key, entry] : objects.items()) {
182 const std::optional<int> object_id = ParseObjectIdKey(key);
183 if (!object_id) {
184 return absl::InvalidArgumentError(
185 absl::StrCat("Bad object ID key \"", key, "\""));
186 }
187 if (!entry.is_object() || !entry.contains("state") ||
188 !entry["state"].is_string()) {
189 return absl::InvalidArgumentError(
190 absl::StrCat("Object ", key, " has no state"));
191 }
192 const std::optional<ObjectEvidenceState> state =
193 ParseObjectEvidenceState(entry["state"].get<std::string>());
194 if (!state) {
195 return absl::InvalidArgumentError(
196 absl::StrCat("Object ", key, " has unknown state \"",
197 entry["state"].get<std::string>(), "\""));
198 }
199 ObjectEvidence evidence;
200 evidence.state = *state;
201 if (entry.contains("note") && entry["note"].is_string()) {
202 evidence.note = entry["note"].get<std::string>();
203 }
204 if (entry.contains("room") && entry["room"].is_number_integer()) {
205 evidence.room_id = entry["room"].get<int>();
206 }
207 if (entry.contains("rom_sha1") && entry["rom_sha1"].is_string()) {
208 evidence.rom_sha1 = entry["rom_sha1"].get<std::string>();
209 }
210 if (entry.contains("updated_utc") && entry["updated_utc"].is_string()) {
211 evidence.updated_utc = entry["updated_utc"].get<std::string>();
212 }
213 store.entries_[*object_id] = std::move(evidence);
214 }
215 return store;
216}
217
218absl::StatusOr<ObjectEvidenceStore> ObjectEvidenceStore::LoadFromFile(
219 const std::filesystem::path& path) {
220 std::error_code ec;
221 if (!std::filesystem::exists(path, ec)) {
222 return ObjectEvidenceStore{};
223 }
224 std::ifstream in(path, std::ios::binary);
225 if (!in) {
226 return absl::UnavailableError(absl::StrCat("Cannot read ", path.string()));
227 }
228 std::ostringstream contents;
229 contents << in.rdbuf();
230 return FromJson(contents.str());
231}
232
234 const std::filesystem::path& path) const {
235 std::error_code ec;
236 if (path.has_parent_path()) {
237 std::filesystem::create_directories(path.parent_path(), ec);
238 if (ec) {
239 return absl::UnavailableError(absl::StrCat(
240 "Cannot create ", path.parent_path().string(), ": ", ec.message()));
241 }
242 }
243 std::filesystem::path temp_path = path;
244 temp_path += ".tmp";
245 {
246 std::ofstream out(temp_path, std::ios::binary | std::ios::trunc);
247 if (!out) {
248 return absl::UnavailableError(
249 absl::StrCat("Cannot write ", temp_path.string()));
250 }
251 out << ToJson();
252 if (!out.good()) {
253 return absl::UnavailableError(
254 absl::StrCat("Failed writing ", temp_path.string()));
255 }
256 }
257 std::filesystem::rename(temp_path, path, ec);
258 if (ec) {
259 std::filesystem::remove(temp_path, ec);
260 return absl::UnavailableError(
261 absl::StrCat("Cannot replace ", path.string()));
262 }
263 return absl::OkStatus();
264}
265
267 const std::vector<zelda3::RoomObject>& objects) {
268 for (size_t i = 0; i < objects.size(); ++i) {
269 const auto& object = objects[i];
270 ObjectOccurrence occurrence;
271 occurrence.room_id = room_id;
272 occurrence.object_index = i;
273 occurrence.x = object.x();
274 occurrence.y = object.y();
275 occurrence.size = object.size_;
276 occurrence.layer = object.GetLayerValue();
277 by_object_[object.id_].push_back(occurrence);
278 }
280}
281
282const std::vector<ObjectOccurrence>* ObjectUsageIndex::Find(
283 int object_id) const {
284 auto it = by_object_.find(object_id);
285 return it == by_object_.end() ? nullptr : &it->second;
286}
287
288int ObjectUsageIndex::RoomCountFor(int object_id) const {
289 const auto* occurrences = Find(object_id);
290 if (occurrences == nullptr) {
291 return 0;
292 }
293 std::set<int> rooms;
294 for (const auto& occurrence : *occurrences) {
295 rooms.insert(occurrence.room_id);
296 }
297 return static_cast<int>(rooms.size());
298}
299
300const std::vector<ReleaseFocusGroup>& ReleaseFocusGroups() {
301 static const std::vector<ReleaseFocusGroup> kGroups = {
302 {"Strips",
303 "Anchor, repeat count and end-cap overlap. Try sizes 0, 1 and 15 and "
304 "placement at the room edge."},
305 {"Bars",
306 "Horizontal, vertical and corner joins. Start with Oracle room 0x042."},
307 {"Water",
308 "Palette, edge versus interior tiles, and each layer on its own. "
309 "Oracle witnesses: 0x04A, then 0x033."},
310 {"Ice and moving floors",
311 "Stamp size and palette in a real room. Oracle ice witnesses: "
312 "0x08C at (10,11) on BG1 and 0x0CE at (29,23) on BG2."},
313 {"Flood controls",
314 "Check the editor indicator only. The in-game water is HDMA, so it is "
315 "not expected to match."},
316 {"Stairs",
317 "Stairs, the wall behind them and doors all visible on the lower "
318 "level. Room 0x077 has spiral stairs."},
319 {"Corners", "Corner and seam tiles, and anchors at the room boundary."},
320 };
321 return kGroups;
322}
323
324int ReleaseFocusGroupFor(int object_id) {
325 const auto& ranges = FocusGroupRanges();
326 for (size_t group = 0; group < ranges.size(); ++group) {
327 for (const IdRange& range : ranges[group]) {
328 if (object_id >= range.first && object_id <= range.last) {
329 return static_cast<int>(group);
330 }
331 }
332 }
333 return -1;
334}
335
336std::vector<int> OrderObjectsForReview(const std::vector<int16_t>& object_ids) {
337 std::vector<int> order(object_ids.begin(), object_ids.end());
338 const int other_group = static_cast<int>(ReleaseFocusGroups().size());
339 auto rank = [other_group](int object_id) {
340 const int group = ReleaseFocusGroupFor(object_id);
341 return group < 0 ? other_group : group;
342 };
343 std::stable_sort(order.begin(), order.end(), [&rank](int a, int b) {
344 const int rank_a = rank(a);
345 const int rank_b = rank(b);
346 return rank_a != rank_b ? rank_a < rank_b : a < b;
347 });
348 return order;
349}
350
351std::optional<int> NextObjectToCheck(const std::vector<int>& review_order,
352 const ObjectEvidenceStore& evidence,
353 const ObjectUsageIndex& usage,
354 std::optional<int> after_object_id) {
355 if (review_order.empty()) {
356 return std::nullopt;
357 }
358 size_t start = 0;
359 if (after_object_id) {
360 auto it =
361 std::find(review_order.begin(), review_order.end(), *after_object_id);
362 if (it != review_order.end()) {
363 start = static_cast<size_t>(it - review_order.begin()) + 1;
364 }
365 }
366 for (size_t step = 0; step < review_order.size(); ++step) {
367 const int object_id = review_order[(start + step) % review_order.size()];
368 if (evidence.StateOf(object_id) == ObjectEvidenceState::kUntriaged &&
369 usage.Find(object_id) != nullptr) {
370 return object_id;
371 }
372 }
373 return std::nullopt;
374}
375
376std::string SafeEvidenceFileName(std::string_view name) {
377 std::string out;
378 out.reserve(name.size());
379 for (const char ch : name) {
380 if (ch >= 'A' && ch <= 'Z') {
381 out.push_back(static_cast<char>(ch - 'A' + 'a'));
382 continue;
383 }
384 const bool keep = (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') ||
385 ch == '_' || ch == '-';
386 out.push_back(keep ? ch : '_');
387 }
388 return out.empty() ? std::string("unnamed") : out;
389}
390
391bool IsPlainCaptureDir(const std::filesystem::path& dir) {
392 for (const auto& part : dir) {
393 if (part == "..") {
394 return false;
395 }
396 }
397 return true;
398}
399
400absl::StatusOr<GameCaptureManifest> LoadGameCaptureManifest(
401 const std::filesystem::path& dir) {
402 if (!IsPlainCaptureDir(dir)) {
403 return absl::InvalidArgumentError(
404 absl::StrCat(dir.string(), " must not contain \"..\""));
405 }
406 const std::filesystem::path path = dir / "manifest.json";
407 std::ifstream in(path, std::ios::binary);
408 if (!in) {
409 return absl::NotFoundError(
410 absl::StrCat("No manifest.json in ", dir.string()));
411 }
412 std::ostringstream contents;
413 contents << in.rdbuf();
414 nlohmann::json root = nlohmann::json::parse(contents.str(), nullptr,
415 /*allow_exceptions=*/false);
416 if (root.is_discarded() || !root.is_object() || !root.contains("rom_sha1") ||
417 !root["rom_sha1"].is_string() || !root.contains("rooms") ||
418 !root["rooms"].is_object()) {
419 return absl::InvalidArgumentError(
420 absl::StrCat(path.string(), " is not a room tilemap capture manifest"));
421 }
422 GameCaptureManifest manifest;
423 manifest.rom_sha1 = root["rom_sha1"].get<std::string>();
424 for (const auto& [key, entry] : root["rooms"].items()) {
425 const std::optional<int> room_id = ParseObjectIdKey(key);
426 if (!room_id || !entry.is_object() || !entry.contains("status") ||
427 !entry["status"].is_string() ||
428 entry["status"].get<std::string>().rfind("ok", 0) != 0) {
429 continue;
430 }
431 manifest.captured_rooms.push_back(*room_id);
432 }
433 std::sort(manifest.captured_rooms.begin(), manifest.captured_rooms.end());
434 return manifest;
435}
436
437std::filesystem::path GameCaptureRoomPath(const std::filesystem::path& dir,
438 int room_id) {
439 return dir / absl::StrFormat("room_%03X.tilemap", room_id);
440}
441
443 placements_.clear();
444 summaries_.clear();
445 rooms_compared_ = 0;
446 rooms_exact_ = 0;
447}
448
449void ObjectAutoCheckResults::Record(int room_id, size_t object_index,
450 int object_id,
451 const PlacementAutoResult& result) {
452 PlacementAutoResult stored = result;
453 stored.object_id = object_id;
454 placements_[{room_id, object_index}] = stored;
455 if (result.tiles_owned == 0) {
456 return;
457 }
458 ObjectSummary& summary = summaries_[object_id];
459 ++summary.placements_checked;
460 if (summary.rooms_checked.empty() ||
461 summary.rooms_checked.back() != room_id) {
462 summary.rooms_checked.push_back(room_id);
463 }
464 if (result.tiles_different > 0) {
465 ++summary.placements_different;
466 summary.difference_bits |= result.difference_bits;
467 if (summary.first_room_different < 0) {
468 summary.first_room_different = room_id;
469 }
470 }
471}
472
474 int room_id, size_t object_index) const {
475 auto it = placements_.find({room_id, object_index});
476 return it == placements_.end() ? nullptr : &it->second;
477}
478
480 int object_id) const {
481 auto it = summaries_.find(object_id);
482 return it == summaries_.end() ? nullptr : &it->second;
483}
484
485std::map<int, ObjectEvidence> ProposeAutomaticVerdicts(
486 const ObjectAutoCheckResults& results, const ObjectEvidenceStore& evidence,
487 const std::string& rom_sha1) {
488 std::map<int, ObjectEvidence> proposals;
489 const std::string rom_label = rom_sha1.substr(0, 8);
490 for (const auto& [object_id, summary] : results.summaries()) {
491 if (summary.placements_checked == 0 ||
492 evidence.Find(object_id) != nullptr) {
493 continue;
494 }
495 ObjectEvidence proposal;
496 proposal.rom_sha1 = rom_sha1;
497 if (summary.placements_different == 0) {
499 proposal.room_id = summary.rooms_checked.front();
500 proposal.note = absl::StrFormat(
501 "Automatic: tilemap matches the game in all %d placements across %d "
502 "rooms (Mesen capture, ROM %s). Colors and graphics not compared.",
503 summary.placements_checked,
504 static_cast<int>(summary.rooms_checked.size()), rom_label);
505 } else {
507 proposal.room_id = summary.first_room_different;
508 proposal.note = absl::StrFormat(
509 "Automatic: %d of %d placements differ from the game (%s); first in "
510 "room 0x%03X (Mesen capture, ROM %s).",
511 summary.placements_different, summary.placements_checked,
512 zelda3::DescribeTileWordDifference(summary.difference_bits),
513 summary.first_room_different, rom_label);
514 }
515 proposals[object_id] = std::move(proposal);
516 }
517 return proposals;
518}
519
520std::map<int, ObjectCustomDrawCode> FindObjectsWithCustomDrawCode(
521 const std::vector<zelda3::ObjectDrawCode>& draw_code,
522 const std::vector<core::ProtectedRegion>& hooks) {
523 std::map<int, ObjectCustomDrawCode> out;
524 for (const auto& code : draw_code) {
526 bool found = false;
527 if (code.jumps_to_expanded_code) {
528 custom.replaced = true;
529 custom.address = code.jump_target;
530 found = true;
531 }
532 for (const auto& hook : hooks) {
533 if (hook.start < code.routine_end && code.routine_start < hook.end) {
534 if (!found) {
535 custom.address = std::max(hook.start, code.routine_start);
536 }
537 custom.module = hook.module;
538 found = true;
539 break;
540 }
541 }
542 if (found) {
543 out.emplace(code.object_id, std::move(custom));
544 }
545 }
546 return out;
547}
548
549std::string FormatObjectId(int object_id) {
550 return absl::StrFormat("0x%03X", object_id);
551}
552
553} // namespace yaze::editor
void Record(int room_id, size_t object_index, int object_id, const PlacementAutoResult &result)
const PlacementAutoResult * Find(int room_id, size_t object_index) const
const std::map< int, ObjectSummary > & summaries() const
std::map< std::pair< int, size_t >, PlacementAutoResult > placements_
std::map< int, ObjectSummary > summaries_
const ObjectSummary * Summary(int object_id) const
const ObjectEvidence * Find(int object_id) const
absl::Status SaveToFile(const std::filesystem::path &path) const
static absl::StatusOr< ObjectEvidenceStore > FromJson(std::string_view json)
ObjectEvidenceState StateOf(int object_id) const
static absl::StatusOr< ObjectEvidenceStore > LoadFromFile(const std::filesystem::path &path)
std::map< int, ObjectEvidence > entries_
void Set(int object_id, ObjectEvidence evidence)
int RoomCountFor(int object_id) const
const std::vector< ObjectOccurrence > * Find(int object_id) const
void AddRoom(int room_id, const std::vector< zelda3::RoomObject > &objects)
std::map< int, std::vector< ObjectOccurrence > > by_object_
const std::vector< std::vector< IdRange > > & FocusGroupRanges()
std::optional< int > ParseObjectIdKey(const std::string &key)
Editors are the view controllers for the application.
std::optional< ObjectEvidenceState > ParseObjectEvidenceState(std::string_view key)
int ReleaseFocusGroupFor(int object_id)
absl::StatusOr< GameCaptureManifest > LoadGameCaptureManifest(const std::filesystem::path &dir)
const char * ObjectEvidenceStateLabel(ObjectEvidenceState state)
std::filesystem::path GameCaptureRoomPath(const std::filesystem::path &dir, int room_id)
bool IsPlainCaptureDir(const std::filesystem::path &dir)
const char * ObjectEvidenceStateKey(ObjectEvidenceState state)
std::string FormatObjectId(int object_id)
const std::vector< ReleaseFocusGroup > & ReleaseFocusGroups()
std::map< int, ObjectEvidence > ProposeAutomaticVerdicts(const ObjectAutoCheckResults &results, const ObjectEvidenceStore &evidence, const std::string &rom_sha1)
std::map< int, ObjectCustomDrawCode > FindObjectsWithCustomDrawCode(const std::vector< zelda3::ObjectDrawCode > &draw_code, const std::vector< core::ProtectedRegion > &hooks)
std::vector< int > OrderObjectsForReview(const std::vector< int16_t > &object_ids)
std::string SafeEvidenceFileName(std::string_view name)
std::optional< int > NextObjectToCheck(const std::vector< int > &review_order, const ObjectEvidenceStore &evidence, const ObjectUsageIndex &usage, std::optional< int > after_object_id)
constexpr ObjectEvidenceState kAllObjectEvidenceStates[]
std::string DescribeTileWordDifference(uint16_t xor_bits)