yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_object_validate_commands.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <fstream>
5#include <limits>
6#include <memory>
7#include <string>
8#include <utility>
9#include <vector>
10
11#include "absl/status/status.h"
12#include "absl/status/statusor.h"
13#include "absl/strings/str_format.h"
16#include "core/features.h"
17#include "rom/rom.h"
25#include "zelda3/dungeon/room.h"
27
28namespace yaze::cli {
29
30namespace {
31
32constexpr int kType1Start = 0x00;
33constexpr int kType1End = 0xF7;
34constexpr int kType2Start = 0x100;
35constexpr int kType2End = 0x13F;
36constexpr int kType3Start = 0xF80;
37constexpr int kType3End = 0xFFF;
38constexpr int kNumRooms = 296;
39
41 bool has_tiles = false;
42 int min_x = 0;
43 int min_y = 0;
44 int max_x = 0;
45 int max_y = 0;
46 int width = 0;
47 int height = 0;
48};
49
51 const std::vector<zelda3::ObjectDrawer::TileTrace>& trace) {
52 TraceBounds bounds;
53 if (trace.empty()) {
54 return bounds;
55 }
56
57 int min_x = std::numeric_limits<int>::max();
58 int min_y = std::numeric_limits<int>::max();
59 int max_x = std::numeric_limits<int>::min();
60 int max_y = std::numeric_limits<int>::min();
61
62 for (const auto& entry : trace) {
63 min_x = std::min(min_x, static_cast<int>(entry.x_tile));
64 min_y = std::min(min_y, static_cast<int>(entry.y_tile));
65 max_x = std::max(max_x, static_cast<int>(entry.x_tile));
66 max_y = std::max(max_y, static_cast<int>(entry.y_tile));
67 }
68
69 bounds.has_tiles = true;
70 bounds.min_x = min_x;
71 bounds.min_y = min_y;
72 bounds.max_x = max_x;
73 bounds.max_y = max_y;
74 bounds.width = max_x - min_x + 1;
75 bounds.height = max_y - min_y + 1;
76 return bounds;
77}
78
79// Choose an object origin (x,y) such that the expected selection bounds rectangle
80// (origin + expected.offset + expected.size) fits inside the 64x64 validation
81// canvas. This avoids false mismatches from traces being clipped at y<0/x<0.
83 const zelda3::DimensionService::DimensionResult& expected_bounds) {
84 constexpr int kRoomMaxX = zelda3::DrawContext::kMaxTilesX - 1;
85 constexpr int kRoomMaxY = zelda3::DrawContext::kMaxTilesY - 1;
86
87 const int min_x = expected_bounds.offset_x_tiles;
88 const int min_y = expected_bounds.offset_y_tiles;
89 const int max_x =
90 expected_bounds.offset_x_tiles + expected_bounds.width_tiles - 1;
91 const int max_y =
92 expected_bounds.offset_y_tiles + expected_bounds.height_tiles - 1;
93
94 const int low_x = std::max(0, -min_x);
95 const int low_y = std::max(0, -min_y);
96 const int high_x = std::min(kRoomMaxX, kRoomMaxX - max_x);
97 const int high_y = std::min(kRoomMaxY, kRoomMaxY - max_y);
98
99 auto choose_axis = [](int low, int high, int room_max) -> int {
100 if (low <= high) {
101 // Prefer origin at 0 when it fits; otherwise pick the nearest in-range.
102 if (0 < low)
103 return low;
104 if (0 > high)
105 return high;
106 return 0;
107 }
108 // No feasible origin; clamp as a best-effort so we still produce output.
109 return std::max(0, std::min(low, room_max));
110 };
111
112 return {choose_axis(low_x, high_x, kRoomMaxX),
113 choose_axis(low_y, high_y, kRoomMaxY)};
114}
115
116} // namespace
117
118namespace detail {
119
121 int object_id, int size,
122 const zelda3::DimensionService::DimensionResult& bounds, int object_x,
123 int object_y) {
124 constexpr int kRoomTilesX = zelda3::DrawContext::kMaxTilesX;
125 constexpr int kRoomTilesY = zelda3::DrawContext::kMaxTilesY;
126 const int room_max_x = kRoomTilesX - 1;
127 const int room_max_y = kRoomTilesY - 1;
128
129 const int min_x = object_x + bounds.offset_x_tiles;
130 const int min_y = object_y + bounds.offset_y_tiles;
131 int max_x = min_x + bounds.width_tiles - 1;
132 int max_y = min_y + bounds.height_tiles - 1;
133
134 if (size > 0) {
135 // Get base dimensions (size=0) through DimensionService.
136 zelda3::RoomObject base_obj(object_id, 0, 0, 0, 0);
137 auto base_dims = zelda3::DimensionService::Get().GetDimensions(base_obj);
138 const int base_w = base_dims.width_tiles;
139 const int base_h = base_dims.height_tiles;
140 const int sel_w = bounds.width_tiles;
141 const int sel_h = bounds.height_tiles;
142
143 const bool extends_h = (sel_w > base_w) && (sel_h == base_h);
144 const bool extends_v = (sel_h > base_h) && (sel_w == base_w);
145
146 if (extends_h && max_x > room_max_x && base_w > 0) {
147 const int delta = sel_w - base_w;
148 if (delta > 0 && (delta % size) == 0) {
149 const int spacing = delta / size;
150 if (spacing > base_w) {
151 // Keep only fully visible repeated segments; drop partial tail repeats.
152 const int extra_room_tiles = room_max_x - (min_x + (base_w - 1));
153 const int max_repeat =
154 std::clamp(std::min(size, extra_room_tiles / spacing), 0, size);
155 const int last_start = min_x + (max_repeat * spacing);
156 const int last_end = last_start + base_w - 1;
157 max_x = std::min(max_x, last_end);
158 }
159 }
160 }
161
162 if (extends_v && max_y > room_max_y && base_h > 0) {
163 const int delta = sel_h - base_h;
164 if (delta > 0 && (delta % size) == 0) {
165 const int spacing = delta / size;
166 if (spacing > base_h) {
167 // Keep only fully visible repeated segments; drop partial tail repeats.
168 const int extra_room_tiles = room_max_y - (min_y + (base_h - 1));
169 const int max_repeat =
170 std::clamp(std::min(size, extra_room_tiles / spacing), 0, size);
171 const int last_start = min_y + (max_repeat * spacing);
172 const int last_end = last_start + base_h - 1;
173 max_y = std::min(max_y, last_end);
174 }
175 }
176 }
177 }
178
179 const int clipped_min_x = std::clamp(min_x, 0, room_max_x);
180 const int clipped_min_y = std::clamp(min_y, 0, room_max_y);
181 const int clipped_max_x = std::clamp(max_x, 0, room_max_x);
182 const int clipped_max_y = std::clamp(max_y, 0, room_max_y);
183
185 clipped.offset_x_tiles = clipped_min_x - object_x;
186 clipped.offset_y_tiles = clipped_min_y - object_y;
187 clipped.width_tiles = std::max(0, clipped_max_x - clipped_min_x + 1);
188 clipped.height_tiles = std::max(0, clipped_max_y - clipped_min_y + 1);
189 return clipped;
190}
191
192} // namespace detail
193
194namespace {
195
196std::vector<int> BuildObjectIds(const absl::StatusOr<int>& object_arg) {
197 std::vector<int> object_ids;
198 if (object_arg.ok()) {
199 object_ids.push_back(object_arg.value());
200 return object_ids;
201 }
202
203 for (int id = kType1Start; id <= kType1End; ++id) {
204 object_ids.push_back(id);
205 }
206 for (int id = kType2Start; id <= kType2End; ++id) {
207 object_ids.push_back(id);
208 }
209 for (int id = kType3Start; id <= kType3End; ++id) {
210 object_ids.push_back(id);
211 }
212 return object_ids;
213}
214
215bool IsType1ObjectId(int object_id) {
216 return object_id >= kType1Start && object_id <= kType1End;
217}
218
219bool IsType2ObjectId(int object_id) {
220 return object_id >= kType2Start && object_id <= kType2End;
221}
222
223bool IsType3ObjectId(int object_id) {
224 return object_id >= kType3Start && object_id <= kType3End;
225}
226
227int EncodedType3Size(int object_id) {
228 // Type 3 stores the low two X bits and low two Y bits in the object ID.
229 // RoomObject::DecodeObjectFromBytes folds the same bits into size_ with the
230 // two bit-pairs swapped, so each Type 3 ID has exactly one encodable size.
231 const int id_low_nibble = object_id & 0x0F;
232 return ((id_low_nibble & 0x03) << 2) | ((id_low_nibble >> 2) & 0x03);
233}
234
235std::vector<int> BuildSizesForObject(int object_id,
236 const absl::StatusOr<int>& size_arg,
237 bool all_sizes) {
238 // Type 2 has no encoded size field. Type 3 folds its size bits into the ID,
239 // so neither subtype can be meaningfully swept independently.
240 if (IsType2ObjectId(object_id)) {
241 return {0};
242 }
243 if (IsType3ObjectId(object_id)) {
244 return {EncodedType3Size(object_id)};
245 }
246 if (size_arg.ok()) {
247 return {size_arg.value()};
248 }
249 if (all_sizes && IsType1ObjectId(object_id)) {
250 std::vector<int> sizes;
251 sizes.reserve(16);
252 for (int size = 0; size <= 0x0F; ++size) {
253 sizes.push_back(size);
254 }
255 return sizes;
256 }
257 return {0, 1, 2, 7, 15};
258}
259
261 public:
262 bool IsChestOpen(int, int) const override { return true; }
263 bool IsBigChestOpen() const override { return true; }
264 bool IsDoorOpen(int, int) const override { return true; }
265 bool IsDoorSwitchActive(int) const override { return true; }
266 bool IsWaterFaceActive(int) const override { return true; }
267 bool IsDamFloodgateOpen(int) const override { return true; }
268 bool IsWallMoved(int) const override { return true; }
269 bool IsFloorBombable(int) const override { return true; }
270 bool IsRupeeFloorCleared(int) const override { return true; }
271 bool IsCrystalSwitchBlue() const override { return false; }
272};
273
274enum class StateProfileKind { kDefault, kActive };
275
277 const char* name = "default";
278 const zelda3::DungeonState* state = nullptr;
279 StateProfileKind kind = StateProfileKind::kDefault;
280};
281
282std::vector<StateProfile> BuildStateProfiles(
283 bool all_states, const ActiveValidationState* active_state) {
284 std::vector<StateProfile> profiles{
285 {"default", nullptr, StateProfileKind::kDefault}};
286 if (all_states) {
287 profiles.push_back({"active", active_state, StateProfileKind::kActive});
288 }
289 return profiles;
290}
291
292bool IsExpectedEmptyState(int object_id, const StateProfile& profile) {
293 // Fail closed: this hand-maintained whitelist contains only USDASM-proven
294 // branches that intentionally erase themselves after their state activates.
295 return profile.kind == StateProfileKind::kActive &&
296 (object_id == 0x0CD || object_id == 0x0CE || object_id == 0xF92 ||
297 object_id == 0xF98);
298}
299
301 const zelda3::RoomObject& object, const zelda3::DungeonState* state,
302 bool expected_empty) {
303 auto geometry =
305 if (geometry.ok() && (expected_empty || (geometry->width_tiles > 0 &&
306 geometry->height_tiles > 0))) {
307 return {
308 .offset_x_tiles = geometry->min_x_tiles,
309 .offset_y_tiles = geometry->min_y_tiles,
310 .width_tiles = geometry->width_tiles,
311 .height_tiles = geometry->height_tiles,
312 };
313 }
315}
316
318 public:
320 : previous_(core::FeatureFlags::get().kEnableCustomObjects) {
321 core::FeatureFlags::get().kEnableCustomObjects = false;
322 }
323
325 core::FeatureFlags::get().kEnableCustomObjects = previous_;
326 }
327
328 private:
329 bool previous_ = false;
330};
331
333 int object_id = 0;
334 int size = 0;
335 std::string state_profile = "default";
336 bool has_tiles = false;
337 bool expected_has_tiles = true;
338 int trace_width = 0;
339 int trace_height = 0;
340 int trace_min_x = 0;
341 int trace_min_y = 0;
342 int expected_width = 0;
343 int expected_height = 0;
344 int expected_offset_x = 0;
345 int expected_offset_y = 0;
346 int trace_offset_x = 0;
347 int trace_offset_y = 0;
348 bool has_room_context = false;
349 int room_id = -1;
350 int object_index = -1;
351 int object_x = 0;
352 int object_y = 0;
353 int object_layer = 0;
354 bool size_mismatch = false;
355 bool offset_mismatch = false;
356
357 std::string FormatText(bool include_room_fields) const {
358 if (!has_tiles) {
359 if (include_room_fields && has_room_context) {
360 return absl::StrFormat(
361 "room 0x%02X obj#%d (0x%03X size %d state %s @%d,%d L%d): no "
362 "tiles drawn",
363 room_id, object_index, object_id, size, state_profile, object_x,
364 object_y, object_layer);
365 }
366 return absl::StrFormat("obj 0x%03X size %d state %s: no tiles drawn",
367 object_id, size, state_profile);
368 }
369 std::string status = (size_mismatch || offset_mismatch) ? "MISMATCH" : "OK";
370 if (include_room_fields && has_room_context) {
371 return absl::StrFormat(
372 "room 0x%02X obj#%d (0x%03X size %d state %s @%d,%d L%d): %s "
373 "trace=%dx%d "
374 "offset=(%d,%d) expected=%dx%d expected_offset=(%d,%d)",
375 room_id, object_index, object_id, size, state_profile, object_x,
376 object_y, object_layer, status, trace_width, trace_height,
377 trace_offset_x, trace_offset_y, expected_width, expected_height,
378 expected_offset_x, expected_offset_y);
379 }
380 return absl::StrFormat(
381 "obj 0x%03X size %d state %s: %s trace=%dx%d min=(%d,%d) "
382 "offset=(%d,%d) "
383 "expected=%dx%d expected_offset=(%d,%d)",
384 object_id, size, state_profile, status, trace_width, trace_height,
385 trace_min_x, trace_min_y, trace_offset_x, trace_offset_y,
386 expected_width, expected_height, expected_offset_x, expected_offset_y);
387 }
388
389 std::string FormatJson(bool include_room_fields) const {
390 if (include_room_fields && has_room_context) {
391 return absl::StrFormat(
392 R"({"object_id":"0x%03X","size":%d,"state_profile":"%s","has_tiles":%s,"expected_has_tiles":%s,)"
393 R"("trace_width":%d,"trace_height":%d,"trace_min_x":%d,"trace_min_y":%d,)"
394 R"("trace_offset_x":%d,"trace_offset_y":%d,)"
395 R"("expected_width":%d,"expected_height":%d,"expected_offset_x":%d,"expected_offset_y":%d,)"
396 R"("room_id":%d,"object_index":%d,"object_x":%d,"object_y":%d,"object_layer":%d,)"
397 R"("size_mismatch":%s,"offset_mismatch":%s})",
398 object_id, size, state_profile, has_tiles ? "true" : "false",
399 expected_has_tiles ? "true" : "false", trace_width, trace_height,
400 trace_min_x, trace_min_y, trace_offset_x, trace_offset_y,
401 expected_width, expected_height, expected_offset_x, expected_offset_y,
402 room_id, object_index, object_x, object_y, object_layer,
403 size_mismatch ? "true" : "false", offset_mismatch ? "true" : "false");
404 }
405 return absl::StrFormat(
406 R"({"object_id":"0x%03X","size":%d,"state_profile":"%s","has_tiles":%s,"expected_has_tiles":%s,)"
407 R"("trace_width":%d,"trace_height":%d,"trace_min_x":%d,"trace_min_y":%d,)"
408 R"("trace_offset_x":%d,"trace_offset_y":%d,)"
409 R"("expected_width":%d,"expected_height":%d,"expected_offset_x":%d,"expected_offset_y":%d,)"
410 R"("size_mismatch":%s,"offset_mismatch":%s})",
411 object_id, size, state_profile, has_tiles ? "true" : "false",
412 expected_has_tiles ? "true" : "false", trace_width, trace_height,
413 trace_min_x, trace_min_y, trace_offset_x, trace_offset_y,
414 expected_width, expected_height, expected_offset_x, expected_offset_y,
415 size_mismatch ? "true" : "false", offset_mismatch ? "true" : "false");
416 }
417};
418
420 std::string json_path;
421 std::string csv_path;
422};
423
425 int object_id = 0;
426 int size = 0;
427 std::string state_profile = "default";
428 bool expected_has_tiles = true;
429 bool has_room_context = false;
430 int room_id = -1;
431 int object_index = -1;
432 int object_x = 0;
433 int object_y = 0;
434 int object_layer = 0;
435 std::vector<zelda3::ObjectDrawer::TileTrace> tiles;
436};
437
438bool EndsWith(const std::string& value, const std::string& suffix) {
439 if (value.size() < suffix.size()) {
440 return false;
441 }
442 return value.compare(value.size() - suffix.size(), suffix.size(), suffix) ==
443 0;
444}
445
446ReportPaths ResolveReportPaths(const std::string& base) {
447 ReportPaths paths{};
448 if (EndsWith(base, ".json")) {
449 paths.json_path = base;
450 paths.csv_path = base.substr(0, base.size() - 5) + ".csv";
451 } else if (EndsWith(base, ".csv")) {
452 paths.csv_path = base;
453 paths.json_path = base.substr(0, base.size() - 4) + ".json";
454 } else {
455 paths.json_path = base + ".json";
456 paths.csv_path = base + ".csv";
457 }
458 return paths;
459}
460
461absl::Status WriteJsonReport(const ReportPaths& paths, bool include_room_fields,
462 int object_count, int size_cases, int state_cases,
463 int test_cases, int mismatch_count,
464 int empty_traces, int expected_empty_traces,
465 int negative_offsets, int skipped_nothing,
466 const std::vector<ValidationResult>& mismatches) {
467 std::ofstream out(paths.json_path);
468 if (!out.is_open()) {
469 return absl::InternalError(
470 absl::StrFormat("Failed to open report file: %s", paths.json_path));
471 }
472
473 out << "{\n";
474 out << " \"summary\": {\n";
475 out << absl::StrFormat(" \"object_count\": %d,\n", object_count);
476 out << absl::StrFormat(" \"size_cases\": %d,\n", size_cases);
477 out << absl::StrFormat(" \"state_cases\": %d,\n", state_cases);
478 out << absl::StrFormat(" \"test_cases\": %d,\n", test_cases);
479 out << absl::StrFormat(" \"mismatch_count\": %d,\n", mismatch_count);
480 out << absl::StrFormat(" \"empty_traces\": %d,\n", empty_traces);
481 out << absl::StrFormat(" \"expected_empty_traces\": %d,\n",
482 expected_empty_traces);
483 out << absl::StrFormat(" \"negative_offsets\": %d,\n", negative_offsets);
484 out << absl::StrFormat(" \"skipped_nothing\": %d\n", skipped_nothing);
485 out << " },\n";
486
487 out << " \"mismatches\": [\n";
488 for (size_t i = 0; i < mismatches.size(); ++i) {
489 out << " " << mismatches[i].FormatJson(include_room_fields);
490 if (i + 1 < mismatches.size()) {
491 out << ",";
492 }
493 out << "\n";
494 }
495 out << " ]\n";
496 out << "}\n";
497 return absl::OkStatus();
498}
499
500absl::Status WriteCsvReport(const ReportPaths& paths, bool include_room_fields,
501 const std::vector<ValidationResult>& mismatches) {
502 std::ofstream out(paths.csv_path);
503 if (!out.is_open()) {
504 return absl::InternalError(
505 absl::StrFormat("Failed to open report file: %s", paths.csv_path));
506 }
507
508 out << "category,object_id,size,state_profile,has_tiles,expected_has_tiles,"
509 "trace_width,trace_height,trace_min_x,trace_min_y,";
510 if (include_room_fields) {
511 out << "trace_offset_x,trace_offset_y,room_id,object_index,object_x,object_"
512 "y,object_layer,";
513 }
514 out << "expected_width,expected_height,expected_offset_x,expected_offset_y,"
515 "size_mismatch,offset_mismatch\n";
516 auto write_row = [&](const ValidationResult& result) {
517 const std::string category = result.has_tiles ? "mismatch" : "empty";
518 out << category << "," << absl::StrFormat("0x%03X", result.object_id) << ","
519 << result.size << "," << result.state_profile << ","
520 << (result.has_tiles ? "true" : "false") << ","
521 << (result.expected_has_tiles ? "true" : "false") << ","
522 << result.trace_width << "," << result.trace_height << ","
523 << result.trace_min_x << "," << result.trace_min_y << ",";
524 if (include_room_fields) {
525 out << result.trace_offset_x << "," << result.trace_offset_y << ","
526 << result.room_id << "," << result.object_index << ","
527 << result.object_x << "," << result.object_y << ","
528 << result.object_layer << ",";
529 }
530 out << "" << result.expected_width << "," << result.expected_height << ","
531 << result.expected_offset_x << "," << result.expected_offset_y << ","
532 << (result.size_mismatch ? "true" : "false") << ","
533 << (result.offset_mismatch ? "true" : "false") << "\n";
534 };
535
536 for (const auto& result : mismatches) {
537 write_row(result);
538 }
539
540 return absl::OkStatus();
541}
542
543std::vector<zelda3::ObjectDrawer::TileTrace> NormalizeTrace(
544 const std::vector<zelda3::ObjectDrawer::TileTrace>& trace) {
545 std::vector<zelda3::ObjectDrawer::TileTrace> normalized = trace;
546 std::sort(normalized.begin(), normalized.end(),
547 [](const zelda3::ObjectDrawer::TileTrace& left,
548 const zelda3::ObjectDrawer::TileTrace& right) {
549 if (left.y_tile != right.y_tile) {
550 return left.y_tile < right.y_tile;
551 }
552 if (left.x_tile != right.x_tile) {
553 return left.x_tile < right.x_tile;
554 }
555 if (left.tile_id != right.tile_id) {
556 return left.tile_id < right.tile_id;
557 }
558 if (left.layer != right.layer) {
559 return left.layer < right.layer;
560 }
561 return left.flags < right.flags;
562 });
563 return normalized;
564}
565
566absl::Status WriteTraceDump(const std::string& path, bool include_room_fields,
567 int unexpected_empty_case_count,
568 int expected_empty_case_count,
569 const std::vector<TraceDumpCase>& cases) {
570 std::ofstream out(path);
571 if (!out.is_open()) {
572 return absl::InternalError(
573 absl::StrFormat("Failed to open trace dump file: %s", path));
574 }
575
576 out << "{\n";
577 out << absl::StrFormat(" \"case_count\": %d,\n",
578 static_cast<int>(cases.size()));
579 out << absl::StrFormat(
580 " \"empty_case_count\": %d,\n",
581 unexpected_empty_case_count + expected_empty_case_count);
582 out << absl::StrFormat(" \"unexpected_empty_case_count\": %d,\n",
583 unexpected_empty_case_count);
584 out << absl::StrFormat(" \"expected_empty_case_count\": %d,\n",
585 expected_empty_case_count);
586 out << " \"cases\": [\n";
587
588 for (size_t i = 0; i < cases.size(); ++i) {
589 const auto& entry = cases[i];
590 out << " {\n";
591 out << absl::StrFormat(" \"object_id\": \"0x%03X\",\n",
592 entry.object_id);
593 out << absl::StrFormat(" \"object_id_dec\": %d,\n", entry.object_id);
594 out << absl::StrFormat(" \"size\": %d,\n", entry.size);
595 out << absl::StrFormat(" \"state_profile\": \"%s\",\n",
596 entry.state_profile);
597 out << absl::StrFormat(" \"expected_has_tiles\": %s,\n",
598 entry.expected_has_tiles ? "true" : "false");
599 if (include_room_fields && entry.has_room_context) {
600 out << absl::StrFormat(" \"room_id\": %d,\n", entry.room_id);
601 out << absl::StrFormat(" \"object_index\": %d,\n",
602 entry.object_index);
603 out << absl::StrFormat(" \"object_x\": %d,\n", entry.object_x);
604 out << absl::StrFormat(" \"object_y\": %d,\n", entry.object_y);
605 out << absl::StrFormat(" \"object_layer\": %d,\n",
606 entry.object_layer);
607 }
608 out << " \"tiles\": [\n";
609
610 for (size_t t = 0; t < entry.tiles.size(); ++t) {
611 const auto& tile = entry.tiles[t];
612 out << absl::StrFormat(
613 " {\"x_tile\":%d,\"y_tile\":%d,"
614 "\"tile_id\":\"0x%04X\",\"tile_id_dec\":%d,"
615 "\"layer\":%d,\"flags\":%d}",
616 tile.x_tile, tile.y_tile, tile.tile_id, tile.tile_id, tile.layer,
617 tile.flags);
618 if (t + 1 < entry.tiles.size()) {
619 out << ",";
620 }
621 out << "\n";
622 }
623 out << " ]\n";
624 out << " }";
625 if (i + 1 < cases.size()) {
626 out << ",";
627 }
628 out << "\n";
629 }
630
631 out << " ]\n";
632 out << "}\n";
633 return absl::OkStatus();
634}
635
636} // namespace
637
638absl::Status DungeonObjectValidateCommandHandler::Execute(
639 Rom* rom, const resources::ArgumentParser& parser,
640 resources::OutputFormatter& formatter) {
641 auto object_arg = parser.GetInt("object");
642 auto size_arg = parser.GetInt("size");
643 auto room_arg = parser.GetInt("room");
644 auto report_arg = parser.GetString("report");
645 auto trace_out_arg = parser.GetString("trace-out");
646 const bool verbose = parser.HasFlag("verbose");
647 const bool all_sizes = parser.HasFlag("all-sizes");
648 const bool all_states = parser.HasFlag("all-states");
649 if (all_sizes && size_arg.ok()) {
650 return absl::InvalidArgumentError(
651 "--all-sizes cannot be combined with --size");
652 }
653 if (size_arg.ok() && (size_arg.value() < 0 || size_arg.value() > 0x0F)) {
654 return absl::InvalidArgumentError("Object size must be between 0 and 15");
655 }
656 const bool room_mode = room_arg.ok();
657 const int room_id = room_mode ? room_arg.value() : -1;
658 if (room_mode && (room_id < 0 || room_id >= kNumRooms)) {
659 return absl::InvalidArgumentError(
660 absl::StrFormat("Room ID must be between 0 and %d", kNumRooms - 1));
661 }
662 if (room_mode && (size_arg.ok() || all_sizes)) {
663 return absl::InvalidArgumentError(
664 "--room cannot be combined with --size or --all-sizes");
665 }
666 if (size_arg.ok() &&
667 (!object_arg.ok() || !IsType1ObjectId(object_arg.value()))) {
668 return absl::InvalidArgumentError(
669 "--size requires a Type 1 --object (0x000-0x0F7)");
670 }
671 const bool write_report = report_arg.has_value();
672 if (write_report && report_arg->empty()) {
673 return absl::InvalidArgumentError("--report requires a non-empty path");
674 }
675
676 // Initialize ObjectDimensionTable so DimensionService's fallback path works.
677 auto& dimension_table = zelda3::ObjectDimensionTable::Get();
678 auto load_status = dimension_table.LoadFromRom(rom);
679 if (!load_status.ok()) {
680 return load_status;
681 }
682 std::vector<int> object_ids = BuildObjectIds(object_arg);
683 ActiveValidationState active_state;
684 const std::vector<StateProfile> state_profiles =
685 BuildStateProfiles(all_states, &active_state);
686
687 std::vector<zelda3::ObjectDrawer::TileTrace> trace;
688 std::vector<std::unique_ptr<zelda3::ObjectDrawer>> profile_drawers;
689 profile_drawers.reserve(state_profiles.size());
690 for (size_t i = 0; i < state_profiles.size(); ++i) {
691 auto drawer = std::make_unique<zelda3::ObjectDrawer>(
692 rom, room_mode ? room_id : 0, nullptr);
693 drawer->SetTraceCollector(&trace, true);
694 profile_drawers.push_back(std::move(drawer));
695 }
696
697 gfx::BackgroundBuffer bg1(512, 512);
698 gfx::BackgroundBuffer bg2(512, 512);
699 gfx::PaletteGroup palette_group;
700
701 int total_tests = 0;
702 int mismatch_count = 0;
703 int empty_trace_count = 0;
704 int expected_empty_trace_count = 0;
705 int negative_offset_count = 0;
706 int skipped_nothing = 0;
707 int room_object_count = 0;
708 std::vector<ValidationResult> mismatches;
709 std::vector<ValidationResult> empty_traces;
710 std::vector<TraceDumpCase> trace_cases;
711
712 int size_case_count = room_mode ? 1 : 0;
713 if (!room_mode) {
714 for (int object_id : object_ids) {
715 size_case_count = std::max(
716 size_case_count,
717 static_cast<int>(
718 BuildSizesForObject(object_id, size_arg, all_sizes).size()));
719 }
720 }
721
722 ReportPaths report_paths;
723 if (write_report) {
724 report_paths = ResolveReportPaths(report_arg.value());
725 }
726 const bool write_trace_dump = trace_out_arg.has_value();
727 if (write_trace_dump) {
728 trace_cases.reserve(object_ids.size() * size_case_count *
729 state_profiles.size());
730 }
731
732 ScopedCustomObjectsDisabled custom_objects_disabled;
733
734 if (room_mode) {
735 zelda3::Room room = zelda3::LoadRoomHeaderFromRom(rom, room_id);
736 room.LoadObjects();
737 for (auto& drawer : profile_drawers) {
738 drawer->SetRoomFloorGraphics(room.floor1(), room.floor2());
739 }
740 const auto& room_objects = room.GetTileObjects();
741 room_object_count = static_cast<int>(room_objects.size());
742 if (write_trace_dump) {
743 trace_cases.reserve(room_objects.size());
744 }
745
746 for (size_t idx = 0; idx < room_objects.size(); ++idx) {
747 const auto& room_obj = room_objects[idx];
748 int object_id = room_obj.id_;
749 int routine_id =
750 zelda3::DrawRoutineRegistry::Get().GetRoutineIdForObject(object_id);
751 if (routine_id == zelda3::DrawRoutineIds::kNothing) {
752 skipped_nothing++;
753 continue;
754 }
755 for (size_t profile_index = 0; profile_index < state_profiles.size();
756 ++profile_index) {
757 const auto& profile = state_profiles[profile_index];
758 auto& drawer = *profile_drawers[profile_index];
759 total_tests++;
760 trace.clear();
761
762 zelda3::RoomObject obj = room_obj;
763 obj.SetRom(rom);
764 const bool expected_empty = IsExpectedEmptyState(object_id, profile);
765 const bool expected_has_tiles = !expected_empty;
766 auto expected_bounds =
767 ResolveExpectedBounds(obj, profile.state, expected_empty);
768 if (expected_has_tiles) {
769 expected_bounds = detail::ClipSelectionBoundsToRoom(
770 object_id, obj.size_, expected_bounds, obj.x_, obj.y_);
771 }
772
773 auto draw_status =
774 drawer.DrawObject(obj, bg1, bg2, palette_group, profile.state);
775 if (!draw_status.ok()) {
776 return draw_status;
777 }
778
779 TraceBounds bounds = ComputeBounds(trace);
780 if (write_trace_dump) {
781 TraceDumpCase trace_case{};
782 trace_case.object_id = object_id;
783 trace_case.size = obj.size_;
784 trace_case.state_profile = profile.name;
785 trace_case.expected_has_tiles = expected_has_tiles;
786 trace_case.has_room_context = true;
787 trace_case.room_id = room_id;
788 trace_case.object_index = static_cast<int>(idx);
789 trace_case.object_x = obj.x_;
790 trace_case.object_y = obj.y_;
791 trace_case.object_layer = obj.GetLayerValue();
792 trace_case.tiles = NormalizeTrace(trace);
793 trace_cases.push_back(std::move(trace_case));
794 }
795
796 ValidationResult result{};
797 result.object_id = object_id;
798 result.size = obj.size_;
799 result.state_profile = profile.name;
800 result.expected_has_tiles = expected_has_tiles;
801 result.has_room_context = true;
802 result.room_id = room_id;
803 result.object_index = static_cast<int>(idx);
804 result.object_x = obj.x_;
805 result.object_y = obj.y_;
806 result.object_layer = obj.GetLayerValue();
807 result.expected_width = expected_bounds.width_tiles;
808 result.expected_height = expected_bounds.height_tiles;
809 result.expected_offset_x = expected_bounds.offset_x_tiles;
810 result.expected_offset_y = expected_bounds.offset_y_tiles;
811
812 if (!bounds.has_tiles) {
813 result.has_tiles = false;
814 if (!expected_has_tiles) {
815 expected_empty_trace_count++;
816 continue;
817 }
818 empty_trace_count++;
819 result.size_mismatch = true;
820 mismatch_count++;
821 mismatches.push_back(result);
822 if (verbose) {
823 empty_traces.push_back(result);
824 }
825 continue;
826 }
827
828 result.has_tiles = true;
829 result.trace_width = bounds.width;
830 result.trace_height = bounds.height;
831 result.trace_min_x = bounds.min_x;
832 result.trace_min_y = bounds.min_y;
833 result.trace_offset_x = bounds.min_x - obj.x_;
834 result.trace_offset_y = bounds.min_y - obj.y_;
835 result.size_mismatch = !expected_has_tiles ||
836 bounds.width != expected_bounds.width_tiles ||
837 bounds.height != expected_bounds.height_tiles;
838 result.offset_mismatch =
839 expected_has_tiles &&
840 (result.trace_offset_x != expected_bounds.offset_x_tiles ||
841 result.trace_offset_y != expected_bounds.offset_y_tiles);
842
843 if (expected_has_tiles && (expected_bounds.offset_x_tiles < 0 ||
844 expected_bounds.offset_y_tiles < 0)) {
845 negative_offset_count++;
846 }
847
848 if (result.size_mismatch || result.offset_mismatch) {
849 mismatch_count++;
850 mismatches.push_back(result);
851 }
852 }
853 }
854 } else {
855 for (int object_id : object_ids) {
856 int routine_id =
857 zelda3::DrawRoutineRegistry::Get().GetRoutineIdForObject(object_id);
858 if (routine_id == zelda3::DrawRoutineIds::kNothing) {
859 skipped_nothing++;
860 continue;
861 }
862 const std::vector<int> object_sizes =
863 BuildSizesForObject(object_id, size_arg, all_sizes);
864 for (int size : object_sizes) {
865 for (size_t profile_index = 0; profile_index < state_profiles.size();
866 ++profile_index) {
867 const auto& profile = state_profiles[profile_index];
868 auto& drawer = *profile_drawers[profile_index];
869 drawer.ResetChestIndex();
870 total_tests++;
871 trace.clear();
872
873 // Measure this concrete state first, then choose an origin that keeps
874 // negative/upward anchors inside the trace canvas.
875 zelda3::RoomObject temp_obj(object_id, 0, 0,
876 static_cast<uint8_t>(size), 0);
877 const bool expected_empty = IsExpectedEmptyState(object_id, profile);
878 const bool expected_has_tiles = !expected_empty;
879 auto expected_bounds =
880 ResolveExpectedBounds(temp_obj, profile.state, expected_empty);
881 const auto [origin_x, origin_y] =
882 expected_has_tiles
883 ? ChooseOriginForExpectedBounds(expected_bounds)
884 : std::pair<int, int>{0, 0};
885
886 zelda3::RoomObject obj(object_id, origin_x, origin_y,
887 static_cast<uint8_t>(size), 0);
888 obj.layer_ = zelda3::RoomObject::LayerType::BG1;
889
890 expected_bounds =
891 ResolveExpectedBounds(obj, profile.state, expected_empty);
892 if (expected_has_tiles) {
893 expected_bounds = detail::ClipSelectionBoundsToRoom(
894 object_id, size, expected_bounds, obj.x_, obj.y_);
895 }
896
897 if (expected_has_tiles && (expected_bounds.offset_x_tiles < 0 ||
898 expected_bounds.offset_y_tiles < 0)) {
899 negative_offset_count++;
900 }
901
902 auto draw_status =
903 drawer.DrawObject(obj, bg1, bg2, palette_group, profile.state);
904 if (!draw_status.ok()) {
905 return draw_status;
906 }
907
908 TraceBounds bounds = ComputeBounds(trace);
909 if (write_trace_dump) {
910 TraceDumpCase trace_case{};
911 trace_case.object_id = object_id;
912 trace_case.size = size;
913 trace_case.state_profile = profile.name;
914 trace_case.expected_has_tiles = expected_has_tiles;
915 trace_case.tiles = NormalizeTrace(trace);
916 trace_cases.push_back(std::move(trace_case));
917 }
918
919 ValidationResult result{};
920 result.object_id = object_id;
921 result.size = size;
922 result.state_profile = profile.name;
923 result.expected_has_tiles = expected_has_tiles;
924 result.expected_width = expected_bounds.width_tiles;
925 result.expected_height = expected_bounds.height_tiles;
926 result.expected_offset_x = expected_bounds.offset_x_tiles;
927 result.expected_offset_y = expected_bounds.offset_y_tiles;
928
929 if (!bounds.has_tiles) {
930 result.has_tiles = false;
931 if (!expected_has_tiles) {
932 expected_empty_trace_count++;
933 continue;
934 }
935 empty_trace_count++;
936 result.size_mismatch = true;
937 mismatch_count++;
938 mismatches.push_back(result);
939 if (verbose) {
940 empty_traces.push_back(result);
941 }
942 continue;
943 }
944
945 result.has_tiles = true;
946 result.trace_width = bounds.width;
947 result.trace_height = bounds.height;
948 result.trace_min_x = bounds.min_x;
949 result.trace_min_y = bounds.min_y;
950 result.trace_offset_x = bounds.min_x - obj.x_;
951 result.trace_offset_y = bounds.min_y - obj.y_;
952 result.size_mismatch = !expected_has_tiles ||
953 bounds.width != expected_bounds.width_tiles ||
954 bounds.height != expected_bounds.height_tiles;
955 result.offset_mismatch =
956 expected_has_tiles &&
957 (result.trace_offset_x != expected_bounds.offset_x_tiles ||
958 result.trace_offset_y != expected_bounds.offset_y_tiles);
959
960 if (result.size_mismatch || result.offset_mismatch) {
961 mismatch_count++;
962 mismatches.push_back(result);
963 }
964 }
965 }
966 }
967 }
968
969 const int object_count =
970 room_mode ? room_object_count : static_cast<int>(object_ids.size());
971 if (write_report) {
972 auto json_status = WriteJsonReport(
973 report_paths, room_mode, object_count, size_case_count,
974 static_cast<int>(state_profiles.size()), total_tests, mismatch_count,
975 empty_trace_count, expected_empty_trace_count, negative_offset_count,
976 skipped_nothing, mismatches);
977 if (!json_status.ok()) {
978 return json_status;
979 }
980
981 auto csv_status = WriteCsvReport(report_paths, room_mode, mismatches);
982 if (!csv_status.ok()) {
983 return csv_status;
984 }
985 }
986
987 if (write_trace_dump) {
988 auto trace_status =
989 WriteTraceDump(trace_out_arg.value(), room_mode, empty_trace_count,
990 expected_empty_trace_count, trace_cases);
991 if (!trace_status.ok()) {
992 return trace_status;
993 }
994 formatter.AddField("trace_dump", trace_out_arg.value());
995 }
996
997 formatter.AddField("object_count", object_count);
998 formatter.AddField("size_cases", size_case_count);
999 formatter.AddField("state_cases", static_cast<int>(state_profiles.size()));
1000 formatter.AddField("test_cases", total_tests);
1001 formatter.AddField("mismatch_count", mismatch_count);
1002 formatter.AddField("empty_traces", empty_trace_count);
1003 formatter.AddField("expected_empty_traces", expected_empty_trace_count);
1004 formatter.AddField("negative_offsets", negative_offset_count);
1005 formatter.AddField("skipped_nothing", skipped_nothing);
1006 if (room_mode) {
1007 formatter.AddField("room_id", room_id);
1008 }
1009 if (write_report) {
1010 formatter.AddField("report_json", report_paths.json_path);
1011 formatter.AddField("report_csv", report_paths.csv_path);
1012 }
1013
1014 formatter.BeginArray("mismatches");
1015 for (const auto& result : mismatches) {
1016 formatter.AddArrayItem(formatter.IsJson() ? result.FormatJson(room_mode)
1017 : result.FormatText(room_mode));
1018 }
1019 formatter.EndArray();
1020
1021 if (verbose) {
1022 formatter.BeginArray("empty_trace_objects");
1023 for (const auto& result : empty_traces) {
1024 formatter.AddArrayItem(formatter.IsJson() ? result.FormatJson(room_mode)
1025 : result.FormatText(room_mode));
1026 }
1027 formatter.EndArray();
1028 }
1029
1030 return absl::OkStatus();
1031}
1032
1033} // namespace yaze::cli
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
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)
Utility for consistent output formatting across commands.
void BeginArray(const std::string &key)
Begin an array.
void AddArrayItem(const std::string &item)
Add an item to current array.
void AddField(const std::string &key, const std::string &value)
Add a key-value pair.
bool IsJson() const
Check if using JSON format.
static DimensionService & Get()
DimensionResult GetDimensions(const RoomObject &obj) const
Interface for accessing dungeon game state.
absl::StatusOr< GeometryBounds > MeasureByObjectIdForState(const RoomObject &object, const DungeonState *state) const
static ObjectGeometry & Get()
void SetRom(Rom *rom)
Definition room_object.h:78
uint8_t GetLayerValue() const
uint8_t floor2() const
Definition room.h:976
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:405
void LoadObjects()
Definition room.cc:1755
uint8_t floor1() const
Definition room.h:975
std::vector< int > BuildObjectIds(const absl::StatusOr< int > &object_arg)
zelda3::DimensionService::DimensionResult ResolveExpectedBounds(const zelda3::RoomObject &object, const zelda3::DungeonState *state, bool expected_empty)
std::pair< int, int > ChooseOriginForExpectedBounds(const zelda3::DimensionService::DimensionResult &expected_bounds)
absl::Status WriteJsonReport(const ReportPaths &paths, bool include_room_fields, int object_count, int size_cases, int state_cases, int test_cases, int mismatch_count, int empty_traces, int expected_empty_traces, int negative_offsets, int skipped_nothing, const std::vector< ValidationResult > &mismatches)
std::vector< StateProfile > BuildStateProfiles(bool all_states, const ActiveValidationState *active_state)
absl::Status WriteTraceDump(const std::string &path, bool include_room_fields, int unexpected_empty_case_count, int expected_empty_case_count, const std::vector< TraceDumpCase > &cases)
std::vector< int > BuildSizesForObject(int object_id, const absl::StatusOr< int > &size_arg, bool all_sizes)
absl::Status WriteCsvReport(const ReportPaths &paths, bool include_room_fields, const std::vector< ValidationResult > &mismatches)
std::vector< zelda3::ObjectDrawer::TileTrace > NormalizeTrace(const std::vector< zelda3::ObjectDrawer::TileTrace > &trace)
TraceBounds ComputeBounds(const std::vector< zelda3::ObjectDrawer::TileTrace > &trace)
zelda3::DimensionService::DimensionResult ClipSelectionBoundsToRoom(int object_id, int size, const zelda3::DimensionService::DimensionResult &bounds, int object_x, int object_y)
Namespace for the command line interface.
Represents a group of palettes.
static constexpr int kMaxTilesY
static constexpr int kMaxTilesX