yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
graphics_doctor_commands.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <iostream>
5#include <optional>
6#include <string>
7#include <vector>
8
9#include "absl/status/status.h"
10#include "absl/strings/str_format.h"
12#include "rom/rom.h"
13#include "rom/snes.h"
15#include "zelda3/game_data.h"
17
18namespace yaze {
19namespace cli {
20
21namespace {
22
24constexpr uint32_t kNumMainBlocksets = 37;
25constexpr uint32_t kNumRoomBlocksets = 82;
26constexpr uint32_t kNumSpritesets = 144;
27// Spriteset value + 0x73 = graphics sheet id.
28constexpr uint32_t kSpriteSheetBase = 0x73;
29
30void AddFinding(DiagnosticReport& report, const std::string& id,
31 DiagnosticSeverity severity, const std::string& message,
32 const std::string& location,
33 const std::string& suggested_action = "") {
34 DiagnosticFinding finding;
35 finding.id = id;
36 finding.severity = severity;
37 finding.message = message;
38 finding.location = location;
39 finding.suggested_action = suggested_action;
40 finding.fixable = false;
41 report.AddFinding(finding);
42}
43
44bool IsSelected(int target_sheet, uint32_t sheet) {
45 return target_sheet < 0 || static_cast<uint32_t>(target_sheet) == sheet;
46}
47
48// Resolves every sheet through the pointer tables at PC 0x4F80 (bank),
49// 0x505F (high) and 0x513E (low), decodes compressed sheets exactly as the
50// game does, and checks the decoded size against the game's WRAM buffer.
51// Extents are collected for all sheets so overlap checks see the whole ROM.
52std::vector<std::optional<zelda3::GfxSheetExtent>> ScanSheets(
53 const Rom& rom, DiagnosticReport& report, int target_sheet, bool verbose,
54 int& successful, int& failed, int& oversized) {
55 std::vector<std::optional<zelda3::GfxSheetExtent>> extents(kNumGfxSheets);
56 int reported = 0;
57 for (uint32_t sheet = 0; sheet < kNumGfxSheets; ++sheet) {
58 auto extent = zelda3::ReadGfxSheetExtent(rom, static_cast<uint16_t>(sheet));
59 const bool selected = IsSelected(target_sheet, sheet);
60 if (!extent.ok()) {
61 if (selected) {
62 ++failed;
63 if (verbose || reported++ < 10) {
64 AddFinding(report, "sheet_unreadable", DiagnosticSeverity::kError,
65 absl::StrFormat("Sheet 0x%02X cannot be read: %s", sheet,
66 extent.status().message()),
67 absl::StrFormat("Sheet 0x%02X", sheet));
68 }
69 }
70 continue;
71 }
72 extents[sheet] = *extent;
73 if (!selected) {
74 continue;
75 }
76 ++successful;
77
78 const size_t expected =
82 if (extent->decoded_size != expected) {
83 ++oversized;
85 report, "sheet_decoded_size", DiagnosticSeverity::kError,
86 absl::StrFormat("Sheet 0x%02X (%s) decodes to 0x%zX bytes; the game "
87 "expects 0x%zX",
88 sheet, zelda3::GfxSheetStorageKindName(extent->kind),
89 extent->decoded_size, expected),
90 absl::StrFormat("Sheet 0x%02X at 0x%06X", sheet, extent->pc),
91 "A longer stream overwrites the next sheet in WRAM; re-save the "
92 "sheet with the graphics editor");
93 }
94 }
95 return extents;
96}
97
98// Two sheets with the same pointer and length share data (vanilla aliases
99// 0x71/0xDD and 0x72/0xDE). Any other overlap means one sheet's stream runs
100// into another's.
102 const std::vector<std::optional<zelda3::GfxSheetExtent>>& extents,
103 DiagnosticReport& report, int target_sheet, int& aliased,
104 int& overlapping) {
105 for (uint32_t a = 0; a < extents.size(); ++a) {
106 if (!extents[a].has_value()) {
107 continue;
108 }
109 for (uint32_t b = a + 1; b < extents.size(); ++b) {
110 if (!extents[b].has_value() ||
111 !(IsSelected(target_sheet, a) || IsSelected(target_sheet, b))) {
112 continue;
113 }
114 const auto& ea = *extents[a];
115 const auto& eb = *extents[b];
116 const uint64_t a_end = ea.pc + ea.stored_size;
117 const uint64_t b_end = eb.pc + eb.stored_size;
118 if (!(ea.pc < b_end && eb.pc < a_end)) {
119 continue;
120 }
121 if (ea.pc == eb.pc && ea.stored_size == eb.stored_size) {
122 ++aliased;
123 AddFinding(report, "sheet_alias", DiagnosticSeverity::kInfo,
124 absl::StrFormat("Sheets 0x%02X and 0x%02X share the same "
125 "data at 0x%06X",
126 a, b, ea.pc),
127 absl::StrFormat("0x%06X", ea.pc));
128 } else {
129 ++overlapping;
131 report, "sheet_overlap", DiagnosticSeverity::kError,
132 absl::StrFormat("Sheet 0x%02X [0x%06X, 0x%06llX) overlaps sheet "
133 "0x%02X [0x%06X, 0x%06llX)",
134 a, ea.pc, static_cast<unsigned long long>(a_end), b,
135 eb.pc, static_cast<unsigned long long>(b_end)),
136 absl::StrFormat("0x%06X", std::max(ea.pc, eb.pc)));
137 }
138 }
139 }
140}
141
142// Main blocksets (word pointer at kGfxGroupsPointer, 37 x 8 sheet ids), room
143// blocksets (kEntranceGfxGroup, 82 x 4 sheet ids) and spritesets
144// (kSpriteBlocksetPointer, 144 x 4 values, sheet = value + 0x73) must name
145// sheets that exist in the 223-entry pointer tables.
146void ValidateGroupTables(const Rom& rom, DiagnosticReport& report,
147 int& invalid_refs) {
148 const auto& data = rom.vector();
149 int reported = 0;
150 auto report_ref = [&](const std::string& group, uint32_t index, int slot,
151 uint32_t pc, uint32_t value, uint32_t sheet) {
152 ++invalid_refs;
153 if (reported++ < 20) {
155 report, "invalid_group_sheet_ref", DiagnosticSeverity::kWarning,
156 absl::StrFormat("%s %u slot %d value 0x%02X names sheet %u "
157 "(the pointer tables hold %u)",
158 group, index, slot, value, sheet, kNumGfxSheets),
159 absl::StrFormat("0x%06X", pc));
160 }
161 };
162
163 if (static_cast<size_t>(zelda3::kGfxGroupsPointer) + 1 < data.size()) {
164 const uint32_t word = data[zelda3::kGfxGroupsPointer] |
165 (data[zelda3::kGfxGroupsPointer + 1] << 8);
166 const uint32_t main_pc = SnesToPc(word);
167 for (uint32_t i = 0; i < kNumMainBlocksets; ++i) {
168 for (int slot = 0; slot < 8; ++slot) {
169 const uint32_t pc = main_pc + i * 8 + slot;
170 if (pc >= data.size()) {
171 break;
172 }
173 if (data[pc] >= kNumGfxSheets) {
174 report_ref("Main blockset", i, slot, pc, data[pc], data[pc]);
175 }
176 }
177 }
178 }
179
180 for (uint32_t i = 0; i < kNumRoomBlocksets; ++i) {
181 for (int slot = 0; slot < 4; ++slot) {
182 const uint32_t pc = zelda3::kEntranceGfxGroup + i * 4 + slot;
183 if (pc >= data.size()) {
184 break;
185 }
186 if (data[pc] >= kNumGfxSheets) {
187 report_ref("Room blockset", i, slot, pc, data[pc], data[pc]);
188 }
189 }
190 }
191
192 for (uint32_t i = 0; i < kNumSpritesets; ++i) {
193 for (int slot = 0; slot < 4; ++slot) {
194 const uint32_t pc = zelda3::kSpriteBlocksetPointer + i * 4 + slot;
195 if (pc >= data.size()) {
196 break;
197 }
198 const uint32_t sheet = data[pc] + kSpriteSheetBase;
199 if (sheet >= kNumGfxSheets) {
200 report_ref("Spriteset", i, slot, pc, data[pc], sheet);
201 }
202 }
203 }
204
205 if (invalid_refs > 0) {
206 AddFinding(report, "group_ref_summary", DiagnosticSeverity::kInfo,
207 absl::StrFormat("Found %d group slot(s) naming a sheet past "
208 "the pointer tables",
209 invalid_refs),
210 "Graphics group tables");
211 }
212}
213
214// Empty sheets are normal (filler and reserved sheets); all-0xFF sheets
215// usually mean erased or uninitialized data.
217 const Rom& rom,
218 const std::vector<std::optional<zelda3::GfxSheetExtent>>& extents,
219 DiagnosticReport& report, int target_sheet, bool verbose, int& empty_sheets,
220 int& suspicious_sheets) {
221 for (uint32_t sheet = 0; sheet < extents.size(); ++sheet) {
222 if (!extents[sheet].has_value() || !IsSelected(target_sheet, sheet)) {
223 continue;
224 }
225 auto data = zelda3::ReadGfxSheetData(rom, static_cast<uint16_t>(sheet));
226 if (!data.ok() || data->empty()) {
227 continue;
228 }
229 const bool all_zero = std::all_of(data->begin(), data->end(),
230 [](uint8_t b) { return b == 0x00; });
231 const bool all_ff = std::all_of(data->begin(), data->end(),
232 [](uint8_t b) { return b == 0xFF; });
233 const std::string location =
234 absl::StrFormat("Sheet 0x%02X at 0x%06X", sheet, extents[sheet]->pc);
235 if (all_zero) {
236 if (verbose || empty_sheets < 10) {
237 AddFinding(report, "empty_sheet", DiagnosticSeverity::kInfo,
238 absl::StrFormat("Sheet 0x%02X is all zeros (empty)", sheet),
239 location);
240 }
241 ++empty_sheets;
242 } else if (all_ff) {
243 if (verbose || suspicious_sheets < 10) {
245 report, "erased_sheet", DiagnosticSeverity::kWarning,
246 absl::StrFormat("Sheet 0x%02X is all 0xFF (erased/uninitialized)",
247 sheet),
248 location, "Sheet may need to be restored");
249 }
250 ++suspicious_sheets;
251 }
252 }
253}
254
255} // namespace
256
258 Rom* rom, const resources::ArgumentParser& parser,
259 resources::OutputFormatter& formatter) {
260 bool verbose = parser.HasFlag("verbose");
261 // Every sheet is always scanned; --all is kept for compatibility.
262 (void)parser.HasFlag("all");
263
264 DiagnosticReport report;
265
266 if (!rom || !rom->is_loaded()) {
267 return absl::InvalidArgumentError("ROM not loaded");
268 }
269
270 // Check for specific sheet
271 auto sheet_arg = parser.GetInt("sheet");
272 bool single_sheet = sheet_arg.ok();
273 int target_sheet = single_sheet ? *sheet_arg : -1;
274
275 if (single_sheet) {
276 if (target_sheet < 0 || target_sheet >= static_cast<int>(kNumGfxSheets)) {
277 return absl::InvalidArgumentError(absl::StrFormat(
278 "Sheet ID %d out of range (0-%d)", target_sheet, kNumGfxSheets - 1));
279 }
280 }
281
282 // 1-2. Resolve pointers, decode every sheet, check decoded sizes
283 int successful_decomp = 0;
284 int failed_decomp = 0;
285 int oversized_sheets = 0;
286 const auto extents =
287 ScanSheets(*rom, report, target_sheet, verbose, successful_decomp,
288 failed_decomp, oversized_sheets);
289 if (single_sheet && extents[target_sheet].has_value()) {
290 formatter.AddField("decoded_size",
291 static_cast<int>(extents[target_sheet]->decoded_size));
292 formatter.AddField("stored_size",
293 static_cast<int>(extents[target_sheet]->stored_size));
294 formatter.AddField("pc_offset",
295 absl::StrFormat("0x%06X", extents[target_sheet]->pc));
296 }
297
298 // 3. Overlapping or aliased sheet data
299 int aliased_sheets = 0;
300 int overlapping_sheets = 0;
301 CheckSheetOverlaps(extents, report, target_sheet, aliased_sheets,
302 overlapping_sheets);
303
304 // 4. Blockset and spriteset references
305 int invalid_group_refs = 0;
306 if (!single_sheet) {
307 ValidateGroupTables(*rom, report, invalid_group_refs);
308 }
309
310 // 5. Sheet contents
311 int empty_sheets = 0;
312 int suspicious_sheets = 0;
313 CheckSheetContents(*rom, extents, report, target_sheet, verbose, empty_sheets,
314 suspicious_sheets);
315
316 // Output results
317 formatter.AddField("total_sheets", static_cast<int>(kNumGfxSheets));
318 formatter.AddField("successful_decompressions", successful_decomp);
319 formatter.AddField("failed_decompressions", failed_decomp);
320 formatter.AddField("oversized_sheets", oversized_sheets);
321 formatter.AddField("overlapping_sheets", overlapping_sheets);
322 formatter.AddField("aliased_sheets", aliased_sheets);
323 formatter.AddField("invalid_group_refs", invalid_group_refs);
324 formatter.AddField("empty_sheets", empty_sheets);
325 formatter.AddField("suspicious_sheets", suspicious_sheets);
326 formatter.AddField("total_findings", report.TotalFindings());
327 formatter.AddField("critical_count", report.critical_count);
328 formatter.AddField("error_count", report.error_count);
329 formatter.AddField("warning_count", report.warning_count);
330 formatter.AddField("info_count", report.info_count);
331
332 // JSON findings array
333 if (formatter.IsJson()) {
334 formatter.BeginArray("findings");
335 for (const auto& finding : report.findings) {
336 formatter.AddArrayItem(finding.FormatJson());
337 }
338 formatter.EndArray();
339 }
340
341 // Text output
342 if (!formatter.IsJson()) {
343 std::cout << "\n";
344 std::cout << "╔════════════════════════════════════════════════════════════"
345 "═══╗\n";
346 std::cout << "║ GRAPHICS DOCTOR "
347 " ║\n";
348 std::cout << "╠════════════════════════════════════════════════════════════"
349 "═══╣\n";
350 std::cout << absl::StrFormat("║ Total Sheets: %-46d ║\n",
351 static_cast<int>(kNumGfxSheets));
352 std::cout << absl::StrFormat("║ Successful Decompressions: %-33d ║\n",
353 successful_decomp);
354 std::cout << absl::StrFormat("║ Failed Decompressions: %-37d ║\n",
355 failed_decomp);
356 std::cout << absl::StrFormat("║ Empty Sheets: %-46d ║\n", empty_sheets);
357 std::cout << absl::StrFormat("║ Suspicious Sheets: %-41d ║\n",
358 suspicious_sheets);
359 std::cout << "╠════════════════════════════════════════════════════════════"
360 "═══╣\n";
361 std::cout << absl::StrFormat(
362 "║ Findings: %d total (%d errors, %d warnings, %d info)%-8s ║\n",
363 report.TotalFindings(), report.error_count, report.warning_count,
364 report.info_count, "");
365 std::cout << "╚════════════════════════════════════════════════════════════"
366 "═══╝\n";
367
368 if (verbose && !report.findings.empty()) {
369 std::cout << "\n=== Detailed Findings ===\n";
370 for (const auto& finding : report.findings) {
371 std::cout << " " << finding.FormatText() << "\n";
372 }
373 } else if (!verbose && report.HasProblems()) {
374 std::cout << "\nUse --verbose to see detailed findings.\n";
375 }
376
377 if (!report.HasProblems()) {
378 std::cout << "\n \033[1;32mNo critical issues found.\033[0m\n";
379 }
380 std::cout << "\n";
381 }
382
383 return absl::OkStatus();
384}
385
386} // namespace cli
387} // namespace yaze
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:49
const auto & vector() const
Definition rom.h:205
bool is_loaded() const
Definition rom.h:187
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.
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.
Locates graphics sheets in the ROM and writes edited sheets back.
void CheckSheetContents(const Rom &rom, const std::vector< std::optional< zelda3::GfxSheetExtent > > &extents, DiagnosticReport &report, int target_sheet, bool verbose, int &empty_sheets, int &suspicious_sheets)
void CheckSheetOverlaps(const std::vector< std::optional< zelda3::GfxSheetExtent > > &extents, DiagnosticReport &report, int target_sheet, int &aliased, int &overlapping)
void AddFinding(DiagnosticReport &report, const std::string &id, DiagnosticSeverity severity, const std::string &message, const std::string &location, const std::string &suggested_action="")
void ValidateGroupTables(const Rom &rom, DiagnosticReport &report, int &invalid_refs)
std::vector< std::optional< zelda3::GfxSheetExtent > > ScanSheets(const Rom &rom, DiagnosticReport &report, int target_sheet, bool verbose, int &successful, int &failed, int &oversized)
DiagnosticSeverity
Severity level for diagnostic findings.
absl::StatusOr< GfxSheetExtent > ReadGfxSheetExtent(const Rom &rom, uint16_t sheet_id, const GfxSheetPointerTables &tables)
constexpr size_t kGfxSheet3bppBytes
constexpr int kSpriteBlocksetPointer
absl::StatusOr< std::vector< uint8_t > > ReadGfxSheetData(const Rom &rom, uint16_t sheet_id, const GfxSheetPointerTables &tables)
Returns the sheet's decoded bytes (SNES planar data).
constexpr size_t kGfxSheet2bppBytes
constexpr uint32_t kEntranceGfxGroup
Definition game_data.h:38
constexpr size_t kGfxSheetCount
const char * GfxSheetStorageKindName(GfxSheetStorageKind kind)
constexpr int kGfxGroupsPointer
uint32_t SnesToPc(uint32_t addr) noexcept
Definition snes.h:8
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.