yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
rom_doctor_commands.cc
Go to the documentation of this file.
2
3#include <cmath>
4#include <iostream>
5#include <numeric>
6
7#include "absl/status/status.h"
8#include "absl/strings/match.h"
9#include "absl/strings/str_format.h"
10#include "absl/strings/str_join.h"
13#include "rom/hm_support.h"
14#include "rom/rom.h"
16
17namespace yaze::cli {
18
19namespace {
20
21// ROM header locations (LoROM)
22// kSnesHeaderBase, kChecksumComplementPos, kChecksumPos defined in diagnostic_types.h
23
24// Expected sizes
25constexpr size_t kVanillaSize = 0x100000; // 1MB
26constexpr size_t kExpandedSize = 0x200000; // 2MB
27
29 std::string title;
30 uint8_t map_mode = 0;
31 uint8_t rom_type = 0;
32 uint8_t rom_size = 0;
33 uint8_t sram_size = 0;
34 uint8_t country = 0;
35 uint8_t license = 0;
36 uint8_t version = 0;
37 uint16_t checksum_complement = 0;
38 uint16_t checksum = 0;
39 bool checksum_valid = false;
40};
41
43 RomHeaderInfo info;
44 const auto& data = rom->data();
45
46 if (rom->size() < yaze::cli::kSnesHeaderBase + 32) {
47 return info;
48 }
49
50 // Read title (21 bytes)
51 for (int i = 0; i < 21; ++i) {
52 char chr = static_cast<char>(data[yaze::cli::kSnesHeaderBase + i]);
53 if (chr >= 32 && chr < 127) {
54 info.title += chr;
55 }
56 }
57
58 // Trim trailing spaces
59 while (!info.title.empty() && info.title.back() == ' ') {
60 info.title.pop_back();
61 }
62
63 info.map_mode = data[yaze::cli::kSnesHeaderBase + 21];
64 info.rom_type = data[yaze::cli::kSnesHeaderBase + 22];
65 info.rom_size = data[yaze::cli::kSnesHeaderBase + 23];
66 info.sram_size = data[yaze::cli::kSnesHeaderBase + 24];
67 info.country = data[yaze::cli::kSnesHeaderBase + 25];
68 info.license = data[yaze::cli::kSnesHeaderBase + 26];
69 info.version = data[yaze::cli::kSnesHeaderBase + 27];
70
71 // Read checksums
73 (data[yaze::cli::kChecksumComplementPos + 1] << 8);
74 info.checksum =
75 data[yaze::cli::kChecksumPos] | (data[yaze::cli::kChecksumPos + 1] << 8);
76
77 // Validate checksum (complement XOR checksum should be 0xFFFF)
78 info.checksum_valid = ((info.checksum_complement ^ info.checksum) == 0xFFFF);
79
80 return info;
81}
82
83std::string GetMapModeName(uint8_t mode) {
84 switch (mode & 0x0F) {
85 case 0x00:
86 return "LoROM";
87 case 0x01:
88 return "HiROM";
89 case 0x02:
90 return "LoROM + S-DD1";
91 case 0x03:
92 return "LoROM + SA-1";
93 case 0x05:
94 return "ExHiROM";
95 default:
96 return absl::StrFormat("Unknown (0x%02X)", mode);
97 }
98}
99
100std::string GetCountryName(uint8_t country) {
101 switch (country) {
102 case 0x00:
103 return "Japan";
104 case 0x01:
105 return "USA";
106 case 0x02:
107 return "Europe";
108 default:
109 return absl::StrFormat("Unknown (0x%02X)", country);
110 }
111}
112
113void OutputTextBanner(bool is_json) {
114 if (is_json)
115 return;
116 std::cout << "\n";
117 std::cout
118 << "╔═══════════════════════════════════════════════════════════════╗\n";
119 std::cout
120 << "║ ROM DOCTOR ║\n";
121 std::cout
122 << "║ File Integrity & Validation Tool ║\n";
123 std::cout
124 << "╚═══════════════════════════════════════════════════════════════╝\n";
125}
126
128 RomFeatures features;
129
130 if (kZSCustomVersionPos < rom->size()) {
131 features.zs_custom_version = rom->data()[kZSCustomVersionPos];
132 features.is_vanilla = (features.zs_custom_version == 0xFF ||
133 features.zs_custom_version == 0x00);
134 features.is_v2 = (!features.is_vanilla && features.zs_custom_version == 2);
135 features.is_v3 = (!features.is_vanilla && features.zs_custom_version >= 3);
136 } else {
137 features.is_vanilla = true;
138 }
139
140 if (!features.is_vanilla) {
141 if (kMap16ExpandedFlagPos < rom->size()) {
142 uint8_t flag = rom->data()[kMap16ExpandedFlagPos];
143 features.has_expanded_tile16 = (flag != 0x0F);
144 }
145
146 if (kMap32ExpandedFlagPos < rom->size()) {
147 uint8_t flag = rom->data()[kMap32ExpandedFlagPos];
148 features.has_expanded_tile32 = (flag != 0x04);
149 }
150 }
151
152 if (kExpandedPtrTableMarker < rom->size()) {
155 }
156
157 return features;
158}
159
160double CalculateEntropy(const uint8_t* data, size_t size) {
161 if (size == 0)
162 return 0.0;
163 std::array<size_t, 256> counts = {0};
164 for (size_t i = 0; i < size; ++i) {
165 counts[data[i]]++;
166 }
167
168 double entropy = 0.0;
169 for (size_t count : counts) {
170 if (count > 0) {
171 double p = static_cast<double>(count) / size;
172 entropy -= p * std::log2(p);
173 }
174 }
175 return entropy;
176}
177
178void CheckCorruptionHeuristics(Rom* rom, DiagnosticReport& report, bool deep) {
179 const auto* data = rom->data();
180 size_t size = rom->size();
181
182 // Check known problematic addresses
183 for (uint32_t addr : kProblemAddresses) {
184 if (addr < size) {
185 if (data[addr] == 0x00) {
186 DiagnosticFinding finding;
187 finding.id = "known_corruption_pattern";
189 finding.message = absl::StrFormat(
190 "Potential corruption detected at known problematic address 0x%06X",
191 addr);
192 finding.location = absl::StrFormat("0x%06X", addr);
193 finding.suggested_action =
194 "Check if this byte should be 0x00. If not, restore from backup.";
195 finding.fixable = false;
196 report.AddFinding(finding);
197 }
198 }
199 }
200
201 // Check for zero-filled blocks in critical code regions (Bank 00)
202 int zero_run = 0;
203 for (uint32_t i = 0x0000; i < 0x1000; ++i) {
204 if (data[i] == 0x00)
205 zero_run++;
206 else
207 zero_run = 0;
208
209 if (zero_run > 64) {
210 DiagnosticFinding finding;
211 finding.id = "bank00_erasure";
213 finding.message = "Large block of zeros detected in Bank 00 code region";
214 finding.location = absl::StrFormat("Around 0x%06X", i);
215 finding.suggested_action =
216 "ROM is likely corrupted. Restore from backup.";
217 finding.fixable = false;
218 report.AddFinding(finding);
219 break;
220 }
221 }
222
223 if (deep) {
224 // Perform full ROM entropy scan per 32KB bank
225 for (uint32_t bank = 0; bank < size / 0x8000; ++bank) {
226 double entropy = CalculateEntropy(data + (bank * 0x8000), 0x8000);
227 if (entropy < 0.5) {
228 DiagnosticFinding finding;
229 finding.id = "low_entropy_bank";
231 finding.message = absl::StrFormat(
232 "Very low entropy (%.2f) detected in Bank %02X. Region might be erased or uninitialized.",
233 entropy, bank);
234 finding.location = absl::StrFormat("Bank %02X", bank);
235 finding.suggested_action = "Verify if this bank should contain data.";
236 finding.fixable = false;
237 report.AddFinding(finding);
238 }
239 }
240
241 // Check for pointer chain integrity in overworld maps
242 uint32_t high_table =
244 uint32_t low_table =
246 int map_count =
248
249 if (high_table + map_count < size && low_table + map_count < size) {
250 for (int i = 0; i < map_count; ++i) {
251 uint8_t high = data[high_table + i];
252 uint16_t low = data[low_table + i] | (data[low_table + i + map_count] << 8);
253 uint32_t target = (high << 16) | low;
254
255 // LoROM address translation (simplified check)
256 uint32_t pc_addr = 0;
257 if ((target & 0x7FFF) >= 0 && target < 0xFF0000) {
258 pc_addr = ((target & 0x7F0000) >> 1) | (target & 0x7FFF);
259 }
260
261 if (pc_addr >= size && target != 0) {
262 DiagnosticFinding finding;
263 finding.id = "invalid_map_pointer";
265 finding.message = absl::StrFormat(
266 "Map %02X points to invalid SNES address 0x%06X", i, target);
267 finding.location = absl::StrFormat("Map %02X Pointer", i);
268 finding.suggested_action = "Fix the pointer in the overworld editor.";
269 finding.fixable = false;
270 report.AddFinding(finding);
271 }
272 }
273 }
274 }
275}
276
278 if (!report.features.has_expanded_tile16)
279 return;
280
281 const auto* data = rom->data();
282 size_t size = rom->size();
283
284 // Check Tile16 expansion region (0x1E8000 - 0x1F0000)
285 if (size >= kMap16TilesExpandedEnd) {
286 bool all_empty = true;
287 for (uint32_t i = kMap16TilesExpanded; i < kMap16TilesExpandedEnd;
288 i += 256) {
289 if (data[i] != 0xFF && data[i] != 0x00) {
290 all_empty = false;
291 break;
292 }
293 }
294
295 if (all_empty) {
296 DiagnosticFinding finding;
297 finding.id = "empty_expanded_tile16";
299 finding.message =
300 "Expanded Tile16 region appears to be empty/uninitialized";
301 finding.location = "0x1E8000-0x1F0000";
302 finding.suggested_action =
303 "Re-save Tile16 data from editor or re-apply expansion patch.";
304 finding.fixable = false;
305 report.AddFinding(finding);
306 }
307 }
308}
309
311 // 1. Search for "PARALLEL WORLDS" string in decoded messages
312 try {
313 std::vector<uint8_t> rom_data_copy = rom->vector(); // Copy for safety
314 // Safety: dummy/invalid ROMs may not contain proper message terminators.
315 // Bound parsing to the ROM size to avoid out-of-bounds reads (signals).
316 auto messages = yaze::editor::ReadAllTextData(
317 rom_data_copy.data(), yaze::editor::kTextData,
318 static_cast<int>(rom_data_copy.size()));
319
320 bool pw_string_found = false;
321 for (const auto& msg : messages) {
322 if (absl::StrContains(msg.ContentsParsed, "PARALLEL WORLDS") ||
323 absl::StrContains(msg.ContentsParsed, "Parallel Worlds")) {
324 pw_string_found = true;
325 break;
326 }
327 }
328
329 if (pw_string_found) {
330 DiagnosticFinding finding;
331 finding.id = "parallel_worlds_string";
333 finding.message = "Found 'PARALLEL WORLDS' string in message data";
334 finding.location = "Message Data";
335 finding.suggested_action = "Confirmed Parallel Worlds ROM.";
336 finding.fixable = false;
337 report.AddFinding(finding);
338 }
339 } catch (...) {
340 // Ignore parsing errors
341 }
342}
343
345 const auto* data = rom->data();
346 size_t size = rom->size();
347
348 bool has_zscustom_features = false;
349 std::vector<std::string> features_found;
350
351 if (kCustomBGEnabledPos < size && data[kCustomBGEnabledPos] != 0x00 &&
352 data[kCustomBGEnabledPos] != 0xFF) {
353 has_zscustom_features = true;
354 features_found.push_back("Custom BG");
355 }
356 if (kCustomMainPalettePos < size && data[kCustomMainPalettePos] != 0x00 &&
357 data[kCustomMainPalettePos] != 0xFF) {
358 has_zscustom_features = true;
359 features_found.push_back("Custom Palette");
360 }
361
362 if (has_zscustom_features && report.features.is_vanilla) {
363 DiagnosticFinding finding;
364 finding.id = "zscustom_features_detected";
366 finding.message = absl::StrFormat(
367 "ZSCustom features detected despite missing version header: %s",
368 absl::StrJoin(features_found, ", "));
369 finding.location = "ZSCustom Flags";
370 finding.suggested_action = "Treat as ZSCustom ROM.";
371 finding.fixable = false;
372 report.AddFinding(finding);
373
374 report.features.is_vanilla = false;
375 report.features.zs_custom_version = 0xFE; // Unknown/Detected
376 }
377}
378
379} // namespace
380
382 Rom* rom, const resources::ArgumentParser& parser,
383 resources::OutputFormatter& formatter) {
384 const bool verbose = parser.HasFlag("verbose");
385 const bool deep = parser.HasFlag("deep");
386 const bool is_json = formatter.IsJson();
387
388 OutputTextBanner(is_json);
389
390 DiagnosticReport report;
391 report.rom_path = rom->filename();
392
393 // Basic ROM info
394 formatter.AddField("rom_path", rom->filename());
395 formatter.AddField("size_bytes", static_cast<int>(rom->size()));
396 formatter.AddHexField("size_hex", rom->size(), 6);
397
398 // Size validation
399 bool size_valid =
400 (rom->size() == kVanillaSize || rom->size() == kExpandedSize);
401 formatter.AddField("size_valid", size_valid);
402
403 if (rom->size() == kVanillaSize) {
404 formatter.AddField("size_type", "vanilla_1mb");
405 } else if (rom->size() == kExpandedSize) {
406 formatter.AddField("size_type", "expanded_2mb");
407 } else {
408 formatter.AddField("size_type", "non_standard");
409
410 DiagnosticFinding finding;
411 finding.id = "non_standard_size";
413 finding.message = absl::StrFormat(
414 "Non-standard ROM size: 0x%zX bytes (expected 0x%zX or 0x%zX)",
415 rom->size(), kVanillaSize, kExpandedSize);
416 finding.location = "ROM file";
417 finding.fixable = false;
418 report.AddFinding(finding);
419 }
420
421 // Read and validate header
422 auto header = ReadRomHeader(rom);
423
424 formatter.AddField("title", header.title);
425 formatter.AddField("map_mode", GetMapModeName(header.map_mode));
426 formatter.AddHexField("rom_type", header.rom_type, 2);
427 formatter.AddField("rom_size_header", 1 << (header.rom_size + 10));
428 formatter.AddField("sram_size",
429 header.sram_size > 0 ? (1 << (header.sram_size + 10)) : 0);
430 formatter.AddField("country", GetCountryName(header.country));
431 formatter.AddField("version", header.version);
432 formatter.AddHexField("checksum_complement", header.checksum_complement, 4);
433 formatter.AddHexField("checksum", header.checksum, 4);
434 formatter.AddField("checksum_valid", header.checksum_valid);
435
436 if (!header.checksum_valid) {
437 DiagnosticFinding finding;
438 finding.id = "invalid_checksum";
440 finding.message = absl::StrFormat(
441 "Invalid SNES checksum: complement=0x%04X checksum=0x%04X (XOR=0x%04X, "
442 "expected 0xFFFF)",
443 header.checksum_complement, header.checksum,
444 header.checksum_complement ^ header.checksum);
445 finding.location =
446 absl::StrFormat("0x%04X", yaze::cli::kChecksumComplementPos);
447 finding.suggested_action =
448 "ROM may be corrupted or modified without checksum update";
449 finding.fixable = true; // Could be fixed by recalculating
450 report.AddFinding(finding);
451 }
452
453 // Detect ZSCustomOverworld version
454 report.features = DetectRomFeaturesLocal(rom);
455 formatter.AddField("zs_custom_version", report.features.GetVersionString());
456 formatter.AddField("is_vanilla", report.features.is_vanilla);
457 formatter.AddField("expanded_tile16", report.features.has_expanded_tile16);
458 formatter.AddField("expanded_tile32", report.features.has_expanded_tile32);
459 formatter.AddField("expanded_pointer_tables",
461
462 // Free space analysis (simplified)
463 if (rom->size() >= kExpandedSize) {
464 // Check for free space in expansion region
465 size_t free_bytes = 0;
466 for (size_t i = 0x180000; i < 0x1E0000 && i < rom->size(); ++i) {
467 if (rom->data()[i] == 0x00 || rom->data()[i] == 0xFF) {
468 free_bytes++;
469 }
470 }
471 formatter.AddField("free_space_estimate", static_cast<int>(free_bytes));
472 formatter.AddField("free_space_region", "0x180000-0x1E0000");
473
474 DiagnosticFinding finding;
475 finding.id = "free_space_info";
477 finding.message = absl::StrFormat(
478 "Estimated free space in expansion region: %zu bytes (%.1f KB)",
479 free_bytes, free_bytes / 1024.0);
480 finding.location = "0x180000-0x1E0000";
481 finding.fixable = false;
482 report.AddFinding(finding);
483 }
484
485 // 3. Check for corruption heuristics
486 CheckCorruptionHeuristics(rom, report, deep);
487
488 // 4. Hyrule Magic / Parallel Worlds Analysis
489 yaze::rom::HyruleMagicValidator hm_validator(rom);
490 if (hm_validator.IsParallelWorlds()) {
491 DiagnosticFinding finding;
492 finding.id = "parallel_worlds_detected";
494 finding.message = "Parallel Worlds (1.5MB) detected (Header check)";
495 finding.location = "ROM Header";
496 finding.suggested_action =
497 "Use z3ed for editing. Custom pointer tables are supported.";
498 finding.fixable = false;
499 report.AddFinding(finding);
500 } else if (hm_validator.HasBank00Erasure()) {
501 DiagnosticFinding finding;
502 finding.id = "hm_corruption_detected";
504 finding.message = "Hyrule Magic corruption detected (Bank 00 erasure)";
505 finding.location = "Bank 00";
506 finding.suggested_action = "ROM is likely unstable. Restore from backup.";
507 finding.fixable = false;
508 report.AddFinding(finding);
509 }
510
511 // Advanced Heuristics
512 CheckParallelWorldsHeuristics(rom, report);
513 CheckZScreamHeuristics(rom, report);
514
515 // 5. Validate expanded tables
516 ValidateExpandedTables(rom, report);
517
518 // 6. Oracle of Secrets: validate WaterFill reserved region integrity.
519 //
520 // This is a ROM safety check for editor-authored water fill zones which
521 // reserve a tail region inside the expanded custom collision bank. If custom
522 // collision data overlaps that reserved tail, the ROM layout is incompatible
523 // with the WaterFill table format and yaze must not attempt to use it.
524 {
525 auto zones_or = yaze::zelda3::LoadWaterFillTable(rom);
526 if (!zones_or.ok()) {
527 DiagnosticFinding finding;
528 finding.id = "water_fill_table_invalid";
530 finding.message = absl::StrFormat("WaterFill table parse failed: %s",
531 zones_or.status().message());
532 finding.location = "Custom collision bank ($13:xxxx)";
533 finding.suggested_action =
534 "Restore from a known-good ROM or fix custom collision layout. "
535 "This must be resolved before using WaterFill authoring.";
536 finding.fixable = false;
537 report.AddFinding(finding);
538 } else if (verbose) {
539 formatter.AddField("water_fill_zone_count",
540 static_cast<int>(zones_or.value().size()));
541 }
542 }
543
544 // Output findings
545 formatter.BeginArray("findings");
546 for (const auto& finding : report.findings) {
547 formatter.AddArrayItem(finding.FormatJson());
548 }
549 formatter.EndArray();
550
551 // Summary
552 formatter.AddField("total_findings", report.TotalFindings());
553 formatter.AddField("critical_count", report.critical_count);
554 formatter.AddField("error_count", report.error_count);
555 formatter.AddField("warning_count", report.warning_count);
556 formatter.AddField("info_count", report.info_count);
557 formatter.AddField("has_problems", report.HasProblems());
558
559 // Text output
560 if (!is_json) {
561 std::cout << "\n";
562 std::cout << "╔════════════════════════════════════════════════════════════"
563 "═══╗\n";
564 std::cout << "║ DIAGNOSTIC SUMMARY "
565 " ║\n";
566 std::cout << "╠════════════════════════════════════════════════════════════"
567 "═══╣\n";
568 std::cout << absl::StrFormat("║ ROM Title: %-49s ║\n", header.title);
569 std::cout << absl::StrFormat("║ Size: 0x%06zX bytes (%zu KB)%-26s ║\n",
570 rom->size(), rom->size() / 1024, "");
571 std::cout << absl::StrFormat("║ Map Mode: %-50s ║\n",
572 GetMapModeName(header.map_mode));
573 std::cout << absl::StrFormat("║ Country: %-51s ║\n",
574 GetCountryName(header.country));
575 std::cout << "╠════════════════════════════════════════════════════════════"
576 "═══╣\n";
577 std::cout << absl::StrFormat(
578 "║ Checksum: 0x%04X (complement: 0x%04X) - %s%-14s ║\n",
579 header.checksum, header.checksum_complement,
580 header.checksum_valid ? "VALID" : "INVALID", "");
581 std::cout << absl::StrFormat("║ ZSCustomOverworld: %-41s ║\n",
582 report.features.GetVersionString());
583 std::cout << absl::StrFormat(
584 "║ Expanded Tile16: %-43s ║\n",
585 report.features.has_expanded_tile16 ? "YES" : "NO");
586 std::cout << absl::StrFormat(
587 "║ Expanded Tile32: %-43s ║\n",
588 report.features.has_expanded_tile32 ? "YES" : "NO");
589 std::cout << absl::StrFormat(
590 "║ Expanded Ptr Tables: %-39s ║\n",
591 report.features.has_expanded_pointer_tables ? "YES" : "NO");
592 std::cout << "╠════════════════════════════════════════════════════════════"
593 "═══╣\n";
594 std::cout << absl::StrFormat(
595 "║ Findings: %d total (%d errors, %d warnings, %d info)%-8s ║\n",
596 report.TotalFindings(), report.error_count, report.warning_count,
597 report.info_count, "");
598 std::cout << "╚════════════════════════════════════════════════════════════"
599 "═══╝\n";
600
601 if (verbose && !report.findings.empty()) {
602 std::cout << "\n=== Detailed Findings ===\n";
603 for (const auto& finding : report.findings) {
604 std::cout << " " << finding.FormatText() << "\n";
605 }
606 }
607 }
608
609 return absl::OkStatus();
610}
611
612} // 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
auto filename() const
Definition rom.h:145
const auto & vector() const
Definition rom.h:143
auto data() const
Definition rom.h:139
auto size() const
Definition rom.h:138
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
Utility for parsing common CLI argument patterns.
bool HasFlag(const std::string &name) const
Check if a flag is present.
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.
void AddHexField(const std::string &key, uint64_t value, int width=2)
Add a hex-formatted field.
void CheckCorruptionHeuristics(Rom *rom, DiagnosticReport &report, bool deep)
double CalculateEntropy(const uint8_t *data, size_t size)
void CheckParallelWorldsHeuristics(Rom *rom, DiagnosticReport &report)
void ValidateExpandedTables(Rom *rom, DiagnosticReport &report)
void CheckZScreamHeuristics(Rom *rom, DiagnosticReport &report)
Namespace for the command line interface.
constexpr uint32_t kExpandedPtrTableLow
constexpr uint32_t kCustomBGEnabledPos
constexpr uint32_t kExpandedPtrTableMarker
constexpr uint32_t kChecksumPos
constexpr uint32_t kPtrTableHighBase
constexpr uint8_t kExpandedPtrTableMagic
constexpr uint32_t kMap32ExpandedFlagPos
constexpr uint32_t kZSCustomVersionPos
constexpr int kExpandedMapCount
constexpr uint32_t kMap16ExpandedFlagPos
constexpr uint32_t kExpandedPtrTableHigh
const uint32_t kProblemAddresses[]
constexpr uint32_t kPtrTableLowBase
constexpr uint32_t kMap16TilesExpanded
constexpr uint32_t kMap16TilesExpandedEnd
constexpr uint32_t kSnesHeaderBase
constexpr int kVanillaMapCount
constexpr uint32_t kChecksumComplementPos
constexpr uint32_t kCustomMainPalettePos
constexpr int kTextData
std::vector< MessageData > ReadAllTextData(uint8_t *rom, int pos, int max_pos)
absl::StatusOr< std::vector< WaterFillZoneEntry > > LoadWaterFillTable(Rom *rom)
A single diagnostic finding.
Complete diagnostic report.
std::vector< DiagnosticFinding > findings
int TotalFindings() const
Get total finding count.
bool HasProblems() const
Check if report has any critical or error findings.
void AddFinding(const DiagnosticFinding &finding)
Add a finding and update counts.
ROM feature detection results.
std::string GetVersionString() const
Get version as human-readable string.