253 const std::filesystem::path& start_path) {
254 namespace fs = std::filesystem;
257 fs::path current = start_path;
258 if (current.empty()) {
259 current = fs::current_path(ec);
261 return absl::InternalError(absl::StrFormat(
262 "Failed to read current directory: %s", ec.message()));
265 current = fs::absolute(current, ec);
267 return absl::InvalidArgumentError(
268 absl::StrFormat(
"Invalid start path: %s", start_path.string()));
270 if (fs::is_regular_file(current, ec) && !ec) {
271 current = current.parent_path();
274 while (!current.empty()) {
275 fs::path menu_entry = current /
"Menu" /
"menu.asm";
276 if (fs::exists(menu_entry, ec) && !ec) {
277 fs::path canonical = fs::weakly_canonical(current, ec);
283 fs::path parent = current.parent_path();
284 if (parent == current) {
290 return absl::NotFoundError(
291 "Could not find Oracle project root (expected Menu/menu.asm)");
295 const std::filesystem::path& project_root) {
296 namespace fs = std::filesystem;
299 const fs::path menu_dir = root /
"Menu";
301 if (!fs::exists(menu_dir, ec) || ec || !fs::is_directory(menu_dir, ec)) {
302 return absl::NotFoundError(
303 absl::StrFormat(
"Missing Menu directory: %s", menu_dir.string()));
309 std::vector<fs::path> asm_paths;
310 fs::recursive_directory_iterator it(
311 menu_dir, fs::directory_options::skip_permission_denied, ec);
312 fs::recursive_directory_iterator end;
314 return absl::InternalError(absl::StrFormat(
315 "Unable to enumerate Menu directory: %s", ec.message()));
317 for (; it != end; it.increment(ec)) {
322 if (!it->is_regular_file()) {
325 const std::string ext = Lowercase(it->path().extension().string());
327 asm_paths.push_back(it->path());
331 std::sort(asm_paths.begin(), asm_paths.end(),
332 [](
const fs::path& a,
const fs::path& b) {
333 return a.generic_string() < b.generic_string();
336 std::unordered_map<std::string, int> draw_references;
337 std::unordered_map<std::string, size_t> draw_indices;
339 for (
const auto& asm_path : asm_paths) {
340 const std::string asm_rel = RelativePathString(root, asm_path);
343 std::ifstream file(asm_path);
344 if (!file.is_open()) {
346 absl::StrFormat(
"Failed to open %s", asm_rel));
350 std::unordered_map<std::string, int> component_indices;
351 std::string current_global_label;
354 while (std::getline(file, line)) {
357 std::string global_label;
358 if (ParseGlobalLabel(line, &global_label)) {
359 current_global_label = global_label;
360 component_indices[current_global_label] = 0;
361 if (ContainsDrawToken(global_label)) {
362 const std::string key = asm_rel +
":" + global_label;
363 if (draw_indices.find(key) == draw_indices.end()) {
366 {.label = global_label, .asm_path = asm_rel, .line = line_no});
371 std::string local_label;
372 if (ParseLocalLabel(line, &local_label) &&
373 ContainsDrawToken(local_label)) {
374 std::string full_label = local_label;
375 if (!current_global_label.empty()) {
376 full_label = current_global_label + local_label;
378 const std::string key = asm_rel +
":" + full_label;
379 if (draw_indices.find(key) == draw_indices.end()) {
388 std::string inline_label;
389 std::string bin_path;
390 if (ParseIncbinLine(line, &inline_label, &bin_path)) {
391 const std::string label =
392 !inline_label.empty() ? inline_label : current_global_label;
394 (asm_path.parent_path() / bin_path).lexically_normal();
395 std::error_code stat_ec;
396 const bool exists = fs::exists(resolved, stat_ec) && !stat_ec;
397 uintmax_t size_bytes = 0;
399 size_bytes = fs::file_size(resolved, stat_ec);
404 registry.
bins.push_back(
408 .bin_path = bin_path,
409 .resolved_bin_path = RelativePathString(root, resolved),
411 .size_bytes = size_bytes});
417 if (ParseMenuOffsetLine(line, &row, &col, ¬e) &&
418 !current_global_label.empty()) {
419 const int index = component_indices[current_global_label]++;
420 registry.
components.push_back({.table_label = current_global_label,
429 std::string draw_target;
430 if (ParseDrawReference(line, &draw_target)) {
431 draw_references[draw_target]++;
437 auto it_ref = draw_references.find(routine.label);
438 if (it_ref != draw_references.end()) {
439 routine.references = it_ref->second;
444 const size_t dot = routine.label.find(
'.');
445 if (dot != std::string::npos) {
446 const std::string local = routine.label.substr(dot);
447 it_ref = draw_references.find(local);
448 if (it_ref != draw_references.end()) {
449 routine.references = it_ref->second;
454 std::sort(registry.
bins.begin(), registry.
bins.end(),
456 if (a.asm_path != b.asm_path) {
457 return a.asm_path < b.asm_path;
459 return a.line < b.line;
461 std::sort(registry.draw_routines.begin(), registry.draw_routines.end(),
462 [](
const OracleMenuDrawRoutine& a,
const OracleMenuDrawRoutine& b) {
463 if (a.asm_path != b.asm_path) {
464 return a.asm_path < b.asm_path;
466 return a.line < b.line;
468 std::sort(registry.components.begin(), registry.components.end(),
469 [](
const OracleMenuComponent& a,
const OracleMenuComponent& b) {
470 if (a.asm_path != b.asm_path) {
471 return a.asm_path < b.asm_path;
473 return a.line < b.line;
485 &report, OracleMenuValidationSeverity::kError,
"no_menu_asm",
486 "No Menu/*.asm files were discovered under the project root.");
489 for (
const auto& warning : registry.
warnings) {
490 AddValidationIssue(&report, OracleMenuValidationSeverity::kWarning,
491 "registry_warning", warning);
494 for (
const auto& entry : registry.
bins) {
497 &report, OracleMenuValidationSeverity::kError,
"missing_bin",
498 absl::StrFormat(
"Missing incbin asset \"%s\" for label %s",
500 entry.label.empty() ?
"(unlabeled)" : entry.label),
501 entry.asm_path, entry.line);
504 if (entry.size_bytes == 0) {
506 &report, OracleMenuValidationSeverity::kWarning,
"empty_bin",
507 absl::StrFormat(
"Incbin asset \"%s\" resolves to 0 bytes",
508 entry.resolved_bin_path),
509 entry.asm_path, entry.line);
514 int component_count = 0;
515 std::set<int> indices;
516 std::map<std::string, int> coordinate_counts;
518 std::map<std::string, TableState> table_states;
520 for (
const auto& component : registry.
components) {
521 if (component.table_label.empty()) {
523 &report, OracleMenuValidationSeverity::kError,
"empty_table_label",
524 "menu_offset entry was parsed without an owning table label",
525 component.asm_path, component.line);
529 if (component.row < 0 || component.col < 0 || component.row > max_row ||
530 component.col > max_col) {
532 &report, OracleMenuValidationSeverity::kError,
533 "component_out_of_bounds",
535 "%s[%d] has out-of-bounds menu_offset(%d,%d); allowed row=0..%d "
537 component.table_label, component.index, component.row,
538 component.col, max_row, max_col),
539 component.asm_path, component.line);
542 TableState& state = table_states[component.table_label];
543 state.component_count++;
545 if (!state.indices.insert(component.index).second) {
547 &report, OracleMenuValidationSeverity::kError,
548 "duplicate_component_index",
549 absl::StrFormat(
"%s contains duplicate index %d",
550 component.table_label, component.index),
551 component.asm_path, component.line);
554 const std::string coord_key =
555 absl::StrFormat(
"%d,%d", component.row, component.col);
556 int& coordinate_count = state.coordinate_counts[coord_key];
558 if (coordinate_count == 2) {
560 &report, OracleMenuValidationSeverity::kWarning,
561 "duplicate_component_coordinate",
563 "%s reuses menu_offset(%d,%d) for multiple component entries",
564 component.table_label, component.row, component.col),
565 component.asm_path, component.line);
569 for (
const auto& [table_label, state] : table_states) {
570 if (state.component_count <= 0) {
574 std::vector<int> missing_indices;
575 missing_indices.reserve(8);
576 for (
int expected = 0; expected < state.component_count; ++expected) {
577 if (state.indices.count(expected) == 0) {
578 missing_indices.push_back(expected);
582 if (!missing_indices.empty()) {
583 std::string missing_list;
584 const int preview_count =
585 std::min<int>(
static_cast<int>(missing_indices.size()), 6);
586 for (
int i = 0; i < preview_count; ++i) {
588 missing_list.append(
", ");
590 missing_list.append(std::to_string(missing_indices[i]));
592 if (
static_cast<int>(missing_indices.size()) > preview_count) {
593 missing_list.append(
", ...");
597 &report, OracleMenuValidationSeverity::kError,
598 "component_index_gap",
600 "%s component indices are not contiguous from 0..%d "
602 table_label, state.component_count - 1, missing_list));
610 const std::filesystem::path& project_root,
611 const std::string& asm_relative_path,
const std::string& table_label,
612 int index,
int row,
int col,
bool write_changes) {
613 namespace fs = std::filesystem;
615 if (asm_relative_path.empty()) {
616 return absl::InvalidArgumentError(
"--asm path is required");
618 if (table_label.empty()) {
619 return absl::InvalidArgumentError(
"--table is required");
622 return absl::InvalidArgumentError(
"--index must be >= 0");
624 if (row < 0 || col < 0) {
625 return absl::InvalidArgumentError(
"--row and --col must be >= 0");
630 fs::path asm_path = fs::path(asm_relative_path);
631 if (!asm_path.is_absolute()) {
632 asm_path = root / asm_path;
634 asm_path = asm_path.lexically_normal();
637 if (!fs::exists(asm_path, ec) || ec || !fs::is_regular_file(asm_path, ec)) {
638 return absl::NotFoundError(
639 absl::StrFormat(
"ASM file not found: %s", asm_path.string()));
641 if (!IsPathWithinRoot(root, asm_path)) {
642 return absl::PermissionDeniedError(absl::StrFormat(
643 "ASM path escapes project root: %s", asm_path.string()));
647 bool trailing_newline =
false;
649 ReadLines(asm_path, &newline, &trailing_newline));
651 bool in_table =
false;
652 int current_index = 0;
653 int target_line = -1;
656 std::string old_line;
657 std::string new_line;
658 static const std::regex kRewritePattern(
659 R
"(^(\s*dw\s+menu_offset\(\s*)([0-9]+)(\s*,\s*)([0-9]+)(\s*\).*)$)",
662 for (
size_t i = 0; i < lines.size(); ++i) {
663 const std::string& line = lines[i];
665 std::string global_label;
666 if (ParseGlobalLabel(line, &global_label)) {
667 if (global_label == table_label) {
670 }
else if (in_table) {
682 if (!ParseMenuOffsetLine(line, &parsed_row, &parsed_col, ¬e)) {
686 if (current_index == index) {
688 if (!std::regex_match(line, match, kRewritePattern)) {
689 return absl::InternalError(absl::StrFormat(
690 "Matched menu_offset but rewrite failed at %s:%d",
691 RelativePathString(root, asm_path),
static_cast<int>(i + 1)));
694 target_line =
static_cast<int>(i + 1);
695 old_row = parsed_row;
696 old_col = parsed_col;
698 new_line = absl::StrFormat(
"%s%d%s%d%s", match[1].str(), row,
699 match[3].str(), col, match[5].str());
705 if (target_line < 0) {
706 return absl::NotFoundError(
707 absl::StrFormat(
"Could not find %s[%d] in %s", table_label, index,
708 RelativePathString(root, asm_path)));
712 result.
asm_path = RelativePathString(root, asm_path);
713 result.
line = target_line;
715 result.
index = index;
722 result.
changed = (old_line != new_line);
725 if (!write_changes) {
730 lines[
static_cast<size_t>(target_line - 1)] = new_line;
731 RETURN_IF_ERROR(WriteLines(asm_path, lines, newline, trailing_newline));