From b6ba7cb57206a9054c7172a9bf4854caa6462e86 Mon Sep 17 00:00:00 2001 From: scawful Date: Fri, 3 Oct 2025 14:12:05 -0400 Subject: [PATCH] Add 'overworld find-tile' command for locating tile instances across maps --- docs/z3ed/E6-z3ed-reference.md | 18 +- docs/z3ed/README.md | 9 + src/cli/handlers/overworld.cc | 305 +++++++++++++++++++++++++++++++++ src/cli/modern_cli.cc | 14 ++ src/cli/modern_cli.h | 1 + src/cli/z3ed.h | 5 + 6 files changed, 349 insertions(+), 3 deletions(-) diff --git a/docs/z3ed/E6-z3ed-reference.md b/docs/z3ed/E6-z3ed-reference.md index fc85d2ae..b144e0c5 100644 --- a/docs/z3ed/E6-z3ed-reference.md +++ b/docs/z3ed/E6-z3ed-reference.md @@ -38,7 +38,7 @@ │ ├─ agent describe [--resource ] │ │ ├─ rom info/validate/diff/generate-golden │ │ ├─ palette export/import/list │ -│ ├─ overworld get-tile/set-tile │ +│ ├─ overworld get-tile/find-tile/set-tile │ │ └─ dungeon list-rooms/add-object │ └────────────────────┬────────────────────────────────────┘ │ @@ -629,12 +629,24 @@ Example: z3ed overworld get-tile --map=0 --x=100 --y=50 ``` +#### `overworld find-tile` - Locate tile instances across maps +```bash +z3ed overworld find-tile --tile [--map ] [--world light|dark|special] [--format json|text] + +Examples: + # Scan entire overworld for tile 0x02E and emit JSON + z3ed overworld find-tile --tile 0x02E --format json + + # Limit search to Light World map 0x05 + z3ed overworld find-tile --tile 0x02E --map 0x05 +``` + #### `overworld set-tile` - Set tile at coordinates ```bash -z3ed overworld set-tile --map --x --y --tile-id +z3ed overworld set-tile --map --x --y --tile Example: - z3ed overworld set-tile --map=0 --x=100 --y=50 --tile-id=0x1234 + z3ed overworld set-tile --map=0 --x=100 --y=50 --tile=0x1234 ``` ### Dungeon Commands diff --git a/docs/z3ed/README.md b/docs/z3ed/README.md index 285a13f0..4c5a94d8 100644 --- a/docs/z3ed/README.md +++ b/docs/z3ed/README.md @@ -194,6 +194,15 @@ z3ed agent diff --latest z3ed agent accept --latest ``` +### Locate Existing Tiles +```bash +# Find every instance of tile 0x02E across the overworld +z3ed overworld find-tile --tile 0x02E --format json + +# Narrow search to Light World map 0x05 +z3ed overworld find-tile --tile 0x02E --map 0x05 +``` + ### Label-Aware Dungeon Edit ```bash # AI uses ResourceLabels from your project diff --git a/src/cli/handlers/overworld.cc b/src/cli/handlers/overworld.cc index 286ae0f8..b2c45571 100644 --- a/src/cli/handlers/overworld.cc +++ b/src/cli/handlers/overworld.cc @@ -1,7 +1,24 @@ #include "cli/z3ed.h" #include "app/zelda3/overworld/overworld.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + #include "absl/flags/flag.h" #include "absl/flags/declare.h" +#include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/match.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_join.h" +#include "absl/strings/str_format.h" ABSL_DECLARE_FLAG(std::string, rom); @@ -98,5 +115,293 @@ absl::Status OverworldSetTile::Run(const std::vector& arg_vec) { return absl::OkStatus(); } +namespace { + +constexpr absl::string_view kFindTileUsage = + "Usage: overworld find-tile --tile [--map ] [--world ] [--format ]"; + +absl::StatusOr ParseNumeric(const std::string& value, int base = 0) { + try { + size_t processed = 0; + int result = std::stoi(value, &processed, base); + if (processed != value.size()) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid numeric value: ", value)); + } + return result; + } catch (const std::exception&) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid numeric value: ", value)); + } +} + +absl::StatusOr WorldFromString(const std::string& value) { + std::string lower = absl::AsciiStrToLower(value); + if (lower == "0" || lower == "light") { + return 0; + } + if (lower == "1" || lower == "dark") { + return 1; + } + if (lower == "2" || lower == "special") { + return 2; + } + return absl::InvalidArgumentError( + absl::StrCat("Unknown world value: ", value)); +} + +absl::StatusOr WorldFromMapId(int map_id) { + if (map_id < 0) { + return absl::InvalidArgumentError("Map ID must be non-negative"); + } + if (map_id < 0x40) { + return 0; + } + if (map_id < 0x80) { + return 1; + } + if (map_id < 0xA0) { + return 2; + } + return absl::InvalidArgumentError( + absl::StrCat("Map ID out of range: 0x", absl::StrFormat("%02X", map_id))); +} + +std::string WorldName(int world) { + switch (world) { + case 0: + return "Light"; + case 1: + return "Dark"; + case 2: + return "Special"; + default: + return absl::StrCat("Unknown(", world, ")"); + } +} + +} // namespace + +absl::Status OverworldFindTile::Run(const std::vector& arg_vec) { + std::unordered_map options; + std::vector positional; + options.reserve(arg_vec.size()); + + for (size_t i = 0; i < arg_vec.size(); ++i) { + const std::string& token = arg_vec[i]; + if (absl::StartsWith(token, "--")) { + std::string key; + std::string value; + auto eq_pos = token.find('='); + if (eq_pos != std::string::npos) { + key = token.substr(2, eq_pos - 2); + value = token.substr(eq_pos + 1); + } else { + key = token.substr(2); + if (i + 1 >= arg_vec.size()) { + return absl::InvalidArgumentError( + absl::StrCat("Missing value for --", key, "\n", kFindTileUsage)); + } + value = arg_vec[++i]; + } + if (value.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("Missing value for --", key, "\n", kFindTileUsage)); + } + options[key] = value; + } else { + positional.push_back(token); + } + } + + if (!positional.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("Unexpected positional arguments: ", + absl::StrJoin(positional, ", "), "\n", kFindTileUsage)); + } + + auto tile_it = options.find("tile"); + if (tile_it == options.end()) { + return absl::InvalidArgumentError( + absl::StrCat("Missing required --tile argument\n", kFindTileUsage)); + } + + ASSIGN_OR_RETURN(int tile_value, ParseNumeric(tile_it->second)); + if (tile_value < 0 || tile_value > 0xFFFF) { + return absl::InvalidArgumentError( + absl::StrCat("Tile ID must be between 0x0000 and 0xFFFF (got ", + tile_it->second, ")")); + } + const uint16_t tile_id = static_cast(tile_value); + + std::optional map_filter; + if (auto map_it = options.find("map"); map_it != options.end()) { + ASSIGN_OR_RETURN(int map_value, ParseNumeric(map_it->second)); + if (map_value < 0 || map_value >= 0xA0) { + return absl::InvalidArgumentError( + absl::StrCat("Map ID out of range: ", map_it->second)); + } + map_filter = map_value; + } + + std::optional world_filter; + if (auto world_it = options.find("world"); world_it != options.end()) { + ASSIGN_OR_RETURN(int parsed_world, WorldFromString(world_it->second)); + world_filter = parsed_world; + } + + if (map_filter.has_value()) { + ASSIGN_OR_RETURN(int inferred_world, WorldFromMapId(*map_filter)); + if (world_filter.has_value() && inferred_world != *world_filter) { + return absl::InvalidArgumentError( + absl::StrCat("Map 0x", + absl::StrFormat("%02X", *map_filter), + " belongs to the ", WorldName(inferred_world), + " World but --world requested ", WorldName(*world_filter))); + } + if (!world_filter.has_value()) { + world_filter = inferred_world; + } + } + + std::string format = "text"; + if (auto format_it = options.find("format"); format_it != options.end()) { + format = absl::AsciiStrToLower(format_it->second); + if (format != "text" && format != "json") { + return absl::InvalidArgumentError( + absl::StrCat("Unsupported format: ", format_it->second)); + } + } + + std::string rom_file = absl::GetFlag(FLAGS_rom); + if (auto rom_it = options.find("rom"); rom_it != options.end()) { + rom_file = rom_it->second; + } + + if (rom_file.empty()) { + return absl::InvalidArgumentError( + "ROM file must be provided via --rom flag."); + } + + auto load_status = rom_.LoadFromFile(rom_file); + if (!load_status.ok()) { + return load_status; + } + if (!rom_.is_loaded()) { + return absl::AbortedError("Failed to load ROM."); + } + + zelda3::Overworld overworld(&rom_); + auto ow_status = overworld.Load(&rom_); + if (!ow_status.ok()) { + return ow_status; + } + + struct TileMatch { + int map_id; + int world; + int local_x; + int local_y; + int global_x; + int global_y; + }; + + std::vector worlds_to_search; + if (world_filter.has_value()) { + worlds_to_search.push_back(*world_filter); + } else { + worlds_to_search = {0, 1, 2}; + } + + std::vector matches; + + for (int world : worlds_to_search) { + int world_start = 0; + int world_maps = 0; + switch (world) { + case 0: + world_start = 0x00; + world_maps = 0x40; + break; + case 1: + world_start = 0x40; + world_maps = 0x40; + break; + case 2: + world_start = 0x80; + world_maps = 0x20; + break; + default: + return absl::InvalidArgumentError( + absl::StrCat("Unknown world index: ", world)); + } + + overworld.set_current_world(world); + + for (int local_map = 0; local_map < world_maps; ++local_map) { + int map_id = world_start + local_map; + if (map_filter.has_value() && map_id != *map_filter) { + continue; + } + + int map_x_index = local_map % 8; + int map_y_index = local_map / 8; + + int global_x_start = map_x_index * 32; + int global_y_start = map_y_index * 32; + + for (int local_y = 0; local_y < 32; ++local_y) { + for (int local_x = 0; local_x < 32; ++local_x) { + int global_x = global_x_start + local_x; + int global_y = global_y_start + local_y; + + uint16_t tile = overworld.GetTile(global_x, global_y); + if (tile == tile_id) { + matches.push_back({map_id, world, local_x, local_y, global_x, + global_y}); + } + } + } + } + } + + if (format == "json") { + std::cout << "{\n"; + std::cout << absl::StrFormat( + " \"tile\": \"0x%04X\",\n", tile_id); + std::cout << absl::StrFormat( + " \"match_count\": %zu,\n", matches.size()); + std::cout << " \"matches\": [\n"; + for (size_t i = 0; i < matches.size(); ++i) { + const auto& match = matches[i]; + std::cout << absl::StrFormat( + " {\"map\": \"0x%02X\", \"world\": \"%s\", " + "\"local\": {\"x\": %d, \"y\": %d}, " + "\"global\": {\"x\": %d, \"y\": %d}}%s\n", + match.map_id, WorldName(match.world), match.local_x, match.local_y, + match.global_x, match.global_y, + (i + 1 == matches.size()) ? "" : ","); + } + std::cout << " ]\n"; + std::cout << "}\n"; + } else { + std::cout << absl::StrFormat( + "🔎 Tile 0x%04X → %zu match(es)\n", tile_id, matches.size()); + if (matches.empty()) { + std::cout << " No matches found." << std::endl; + return absl::OkStatus(); + } + + for (const auto& match : matches) { + std::cout << absl::StrFormat( + " • Map 0x%02X (%s World) local(%2d,%2d) global(%3d,%3d)\n", + match.map_id, WorldName(match.world), match.local_x, match.local_y, + match.global_x, match.global_y); + } + } + + return absl::OkStatus(); +} + } // namespace cli } // namespace yaze diff --git a/src/cli/modern_cli.cc b/src/cli/modern_cli.cc index c21c4d61..0257280c 100644 --- a/src/cli/modern_cli.cc +++ b/src/cli/modern_cli.cc @@ -201,6 +201,15 @@ void ModernCLI::SetupCommands() { } }; + commands_["overworld find-tile"] = { + .name = "overworld find-tile", + .description = "Search overworld maps for a tile ID", + .usage = "z3ed overworld find-tile --tile [--map ] [--world light|dark|special] [--format json|text]", + .handler = [this](const std::vector& args) -> absl::Status { + return HandleOverworldFindTileCommand(args); + } + }; + commands_["overworld set-tile"] = { .name = "overworld set-tile", .description = "Set a tile in the overworld", @@ -434,6 +443,11 @@ absl::Status ModernCLI::HandleOverworldGetTileCommand(const std::vector& args) { + OverworldFindTile handler; + return handler.Run(args); +} + absl::Status ModernCLI::HandleOverworldSetTileCommand(const std::vector& args) { OverworldSetTile handler; return handler.Run(args); diff --git a/src/cli/modern_cli.h b/src/cli/modern_cli.h index 1f383d46..aebc6c94 100644 --- a/src/cli/modern_cli.h +++ b/src/cli/modern_cli.h @@ -52,6 +52,7 @@ class ModernCLI { absl::Status HandlePaletteCommand(const std::vector& args); absl::Status HandleRomValidateCommand(const std::vector& args); absl::Status HandleOverworldGetTileCommand(const std::vector& args); + absl::Status HandleOverworldFindTileCommand(const std::vector& args); absl::Status HandleOverworldSetTileCommand(const std::vector& args); absl::Status HandleSpriteCreateCommand(const std::vector& args); }; diff --git a/src/cli/z3ed.h b/src/cli/z3ed.h index 593377c8..57307fec 100644 --- a/src/cli/z3ed.h +++ b/src/cli/z3ed.h @@ -147,6 +147,11 @@ class OverworldSetTile : public CommandHandler { absl::Status Run(const std::vector& arg_vec) override; }; +class OverworldFindTile : public CommandHandler { + public: + absl::Status Run(const std::vector& arg_vec) override; +}; + class SpriteCreate : public CommandHandler { public: absl::Status Run(const std::vector& arg_vec) override;