yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
overworld_sprite_edit_commands.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <string>
5#include <system_error>
6#include <vector>
7
8#include "absl/strings/numbers.h"
9#include "absl/strings/str_format.h"
10#include "absl/strings/str_join.h"
12#include "cli/util/hex_util.h"
13#include "rom/rom.h"
14#include "rom/transaction.h"
15#include "util/macro.h"
21
22namespace yaze {
23namespace cli {
24namespace handlers {
25
26namespace {
27
28struct Entry {
29 uint8_t y; // raw byte: flags in bits 6-7, tile in bits 0-5
30 uint8_t x;
31 uint8_t id;
32};
33
34std::vector<Entry> Decode(const zelda3::OverworldSpriteBytes& bytes) {
35 std::vector<Entry> out;
36 for (size_t i = 0; i + 3 <= bytes.size() && bytes[i] != 0xFF; i += 3)
37 out.push_back({bytes[i], bytes[i + 1], bytes[i + 2]});
38 return out;
39}
40
41zelda3::OverworldSpriteBytes Encode(const std::vector<Entry>& entries) {
43 for (const auto& e : entries) {
44 out.push_back(e.y);
45 out.push_back(e.x);
46 out.push_back(e.id);
47 }
48 out.push_back(0xFF);
49 return out;
50}
51
52std::string Hex(const std::vector<uint8_t>& bytes) {
53 std::vector<std::string> parts;
54 parts.reserve(bytes.size());
55 for (uint8_t b : bytes)
56 parts.push_back(absl::StrFormat("%02X", b));
57 return absl::StrJoin(parts, " ");
58}
59
60std::string Snes(int pc) {
61 // LoROM: bank = pc / 0x8000, address = $8000 | (pc & 0x7FFF).
62 return absl::StrFormat("$%02X:%04X", pc / 0x8000, 0x8000 | (pc & 0x7FFF));
63}
64
65void AddEntries(resources::OutputFormatter& formatter, const std::string& key,
66 const std::vector<Entry>& entries) {
67 formatter.BeginArray(key);
68 for (size_t i = 0; i < entries.size(); ++i) {
69 const auto& e = entries[i];
70 formatter.BeginObject();
71 formatter.AddField("index", static_cast<int>(i));
72 formatter.AddField("sprite_id", absl::StrFormat("0x%02X", e.id));
73 formatter.AddField("name", zelda3::ResolveSpriteName(e.id));
74 formatter.AddField("tile",
75 absl::StrFormat("(%d,%d)", e.x & 0x3F, e.y & 0x3F));
76 formatter.AddField("bytes",
77 absl::StrFormat("%02X %02X %02X", e.y, e.x, e.id));
78 formatter.EndObject();
79 }
80 formatter.EndArray();
81}
82
83absl::StatusOr<int> RequiredInt(const resources::ArgumentParser& parser,
84 const std::string& name) {
85 if (!parser.GetString(name).has_value())
86 return absl::InvalidArgumentError(
87 absl::StrFormat("--%s is required", name));
88 return parser.GetInt(name);
89}
90
91} // namespace
92
93bool IsOracleProjectRomPath(const std::filesystem::path& rom_path) {
94 std::error_code ec;
95 auto canonical = std::filesystem::weakly_canonical(rom_path, ec);
96 if (ec)
97 canonical = std::filesystem::absolute(rom_path, ec);
98 const auto checkout = canonical.parent_path().parent_path();
99 return std::filesystem::exists(checkout / "Oracle_main.asm", ec);
100}
101
103 switch (action_) {
105 return "overworld-add-sprite";
107 return "overworld-move-sprite";
109 return "overworld-remove-sprite";
110 }
111 return "overworld-sprite-edit";
112}
113
115 switch (action_) {
117 return "Add a sprite to one overworld phase list (dry-run by default)";
119 return "Move a sprite in one overworld phase list (dry-run by default)";
121 return "Remove a sprite from one overworld phase list (dry-run by "
122 "default)";
123 }
124 return "";
125}
126
128 const std::string common =
129 " [--write] [--allow-project-rom] [--format <json|text>]";
130 switch (action_) {
132 return "overworld-add-sprite --screen <hex> --phase <0|1|2> --id <hex> "
133 "--x <tile> --y <tile> [--replace-index <n>]" +
134 common;
136 return "overworld-move-sprite --screen <hex> --phase <0|1|2> --index <n> "
137 "--x <tile> --y <tile> [--expect-id <hex>]" +
138 common;
140 return "overworld-remove-sprite --screen <hex> --phase <0|1|2> --index "
141 "<n> [--expect-id <hex>]" +
142 common;
143 }
144 return "";
145}
146
148 const resources::ArgumentParser& parser) {
149 RETURN_IF_ERROR(parser.RequireArgs({"screen", "phase"}));
150 switch (action_) {
152 return parser.RequireArgs({"id", "x", "y"});
154 return parser.RequireArgs({"index", "x", "y"});
156 return parser.RequireArgs({"index"});
157 }
158 return absl::OkStatus();
159}
160
162 Rom* rom, const resources::ArgumentParser& parser,
163 resources::OutputFormatter& formatter) {
164 resources::CommandInvocationContext invocation_context;
165 if (rom != nullptr && !rom->filename().empty()) {
166 invocation_context.source_rom_path = std::filesystem::path(rom->filename());
167 invocation_context.active_rom_path = std::filesystem::path(rom->filename());
168 }
169 return ExecuteWithContext(rom, parser, formatter, invocation_context);
170}
171
173 Rom* rom, const resources::ArgumentParser& parser,
175 const resources::CommandInvocationContext& invocation_context) {
177 if (!rom || !rom->is_loaded())
178 return absl::FailedPreconditionError("ROM not loaded");
179
180 int screen = 0;
181 if (!util::ParseHexString(*parser.GetString("screen"), &screen))
182 return absl::InvalidArgumentError("--screen must be hex (e.g. 0x40)");
183 int phase = -1;
184 if (!absl::SimpleAtoi(*parser.GetString("phase"), &phase) || phase < 0 ||
185 phase > 2) {
186 return absl::InvalidArgumentError("--phase must be 0, 1, or 2");
187 }
188 const bool do_write = parser.HasFlag("write");
189
190 // Refuse writes to a ROM that lives in an Oracle source checkout before
191 // touching anything; agents must edit a copy.
192 const auto active_path = invocation_context.active_rom_path.value_or(
193 std::filesystem::path(rom->filename()));
194 if (do_write && !parser.HasFlag("allow-project-rom") &&
195 IsOracleProjectRomPath(active_path)) {
196 return absl::PermissionDeniedError(absl::StrFormat(
197 "Refusing --write to %s: it is inside an Oracle of Secrets checkout "
198 "(Oracle_main.asm next to its Roms/ folder). Copy the ROM (with "
199 "Data/dungeons/custom_collision.json) and write the copy, or pass "
200 "--allow-project-rom deliberately.",
201 active_path.string()));
202 }
203
204 zelda3::Overworld overworld(rom);
205 RETURN_IF_ERROR(overworld.Load(rom));
206 const auto* map = overworld.overworld_map(screen);
207 if (map == nullptr)
208 return absl::InvalidArgumentError(
209 absl::StrFormat("Screen 0x%02X is out of range", screen));
210 const int parent = map->parent();
211 int cols = 1, rows = 1;
212 switch (overworld.overworld_map(parent)->area_size()) {
214 cols = rows = 2;
215 break;
217 cols = 2;
218 break;
220 rows = 2;
221 break;
222 default:
223 if (overworld.overworld_map(parent)->is_large_map())
224 cols = rows = 2;
225 break;
226 }
227
228 const auto layout = zelda3::GetOverworldSpriteLayout(*rom);
229 if (parent >= layout.counts[phase]) {
230 return absl::InvalidArgumentError(absl::StrFormat(
231 "Phase %d has no sprite list for area 0x%02X in this ROM "
232 "(%d entries)",
233 phase, parent, layout.counts[phase]));
234 }
235 const int pointer_pc = layout.tables[phase] + parent * 2;
236 ASSIGN_OR_RETURN(auto old_bytes,
237 zelda3::ReadOverworldSpriteList(*rom, pointer_pc));
238 const auto before = Decode(old_bytes);
239 auto after = before;
240
241 auto check_tile = [&](int x, int y) -> absl::Status {
242 if (x < 0 || y < 0 || x >= cols * 32 || y >= rows * 32) {
243 return absl::OutOfRangeError(absl::StrFormat(
244 "Tile (%d,%d) is outside area 0x%02X (%dx%d tiles of 16px)", x, y,
245 parent, cols * 32, rows * 32));
246 }
247 return absl::OkStatus();
248 };
249 auto check_index = [&](const std::string& flag) -> absl::StatusOr<int> {
250 ASSIGN_OR_RETURN(int index, RequiredInt(parser, flag));
251 if (index < 0 || index >= static_cast<int>(before.size())) {
252 return absl::OutOfRangeError(
253 absl::StrFormat("--%s %d out of range (list has %d entries)", flag,
254 index, static_cast<int>(before.size())));
255 }
256 if (auto expect = parser.GetString("expect-id"); expect.has_value()) {
257 int id = 0;
258 if (!util::ParseHexString(*expect, &id) || before[index].id != id) {
259 return absl::FailedPreconditionError(
260 absl::StrFormat("--expect-id %s does not match entry %d (0x%02X)",
261 *expect, index, before[index].id));
262 }
263 }
264 return index;
265 };
266
267 formatter.AddField("mode", do_write ? "write" : "dry-run");
268 formatter.AddField("action", GetName());
269 formatter.AddField("requested_screen", absl::StrFormat("0x%02X", screen));
270 formatter.AddField("parent_area", absl::StrFormat("0x%02X", parent));
271 formatter.AddField("area_tiles",
272 absl::StrFormat("%dx%d", cols * 32, rows * 32));
273 formatter.AddField("phase", phase);
274 formatter.AddField("phase_name", overworld::SpritePhaseName(phase));
275
276 switch (action_) {
278 int id = 0;
279 if (!util::ParseHexString(*parser.GetString("id"), &id) || id < 0 ||
280 id > 0xFF) {
281 return absl::InvalidArgumentError("--id must be hex 0x00-0xFF");
282 }
283 ASSIGN_OR_RETURN(int x, RequiredInt(parser, "x"));
284 ASSIGN_OR_RETURN(int y, RequiredInt(parser, "y"));
285 RETURN_IF_ERROR(check_tile(x, y));
286 if (parser.GetString("replace-index").has_value()) {
287 ASSIGN_OR_RETURN(int replace, check_index("replace-index"));
288 formatter.AddField("replaced_index", replace);
289 after.erase(after.begin() + replace);
290 }
291 after.push_back({static_cast<uint8_t>(y), static_cast<uint8_t>(x),
292 static_cast<uint8_t>(id)});
293 formatter.AddField("sprite_id", absl::StrFormat("0x%02X", id));
294 formatter.AddField("sprite_name", zelda3::ResolveSpriteName(id));
295 break;
296 }
298 ASSIGN_OR_RETURN(int index, check_index("index"));
299 ASSIGN_OR_RETURN(int x, RequiredInt(parser, "x"));
300 ASSIGN_OR_RETURN(int y, RequiredInt(parser, "y"));
301 RETURN_IF_ERROR(check_tile(x, y));
302 after[index].x = static_cast<uint8_t>((after[index].x & 0xC0) | x);
303 after[index].y = static_cast<uint8_t>((after[index].y & 0xC0) | y);
304 formatter.AddField("index", index);
305 break;
306 }
308 ASSIGN_OR_RETURN(int index, check_index("index"));
309 after.erase(after.begin() + index);
310 formatter.AddField("index", index);
311 break;
312 }
313 }
314
315 AddEntries(formatter, "list_before", before);
316 AddEntries(formatter, "list_after", after);
317 // Exact duplicate entries are a common source of wasted list bytes.
318 formatter.BeginArray("duplicate_entries_before");
319 for (size_t i = 0; i < before.size(); ++i) {
320 for (size_t j = 0; j < i; ++j) {
321 if (before[i].x == before[j].x && before[i].y == before[j].y &&
322 before[i].id == before[j].id) {
323 formatter.AddArrayItem(absl::StrFormat("index %d duplicates index %d",
324 static_cast<int>(i),
325 static_cast<int>(j)));
326 break;
327 }
328 }
329 }
330 formatter.EndArray();
331
332 const auto new_bytes = Encode(after);
333 formatter.AddField(
334 "pointer_slot",
335 absl::StrFormat("PC $%06X / SNES %s", pointer_pc, Snes(pointer_pc)));
336 auto plan_or =
337 zelda3::PlanOverworldSpriteListEdit(*rom, phase, parent, new_bytes);
338 if (!plan_or.ok()) {
339 formatter.AddField("strategy", "refused");
340 formatter.AddField("error", std::string(plan_or.status().message()));
341 return plan_or.status();
342 }
343 const auto& plan = *plan_or;
344 formatter.AddField("strategy",
346 formatter.AddField("old_list",
347 absl::StrFormat("PC $%06X / SNES %s, %d bytes",
348 plan.old_list_pc, Snes(plan.old_list_pc),
349 static_cast<int>(plan.old_bytes.size())));
350 formatter.AddField("new_list",
351 absl::StrFormat("PC $%06X / SNES %s, %d bytes",
352 plan.new_list_pc, Snes(plan.new_list_pc),
353 static_cast<int>(plan.new_bytes.size())));
354 formatter.AddField("old_list_shared_with_slots",
355 static_cast<int>(plan.sharers.size()));
356 formatter.AddField("region",
357 absl::StrFormat("PC $%06X-$%06X (%d unreferenced bytes)",
358 plan.region_start, plan.region_end,
359 plan.unreferenced_bytes));
360 formatter.BeginArray("writes");
361 const auto& rom_bytes = rom->vector();
362 for (const auto& write : plan.writes) {
363 const std::vector<uint8_t> old(
364 rom_bytes.begin() + write.address,
365 rom_bytes.begin() + write.address + write.bytes.size());
366 formatter.BeginObject();
367 formatter.AddField("pc", absl::StrFormat("$%06X", write.address));
368 formatter.AddField("snes", Snes(write.address));
369 formatter.AddField("length", static_cast<int>(write.bytes.size()));
370 formatter.AddField("old", Hex(old));
371 formatter.AddField("new", Hex(write.bytes));
372 formatter.EndObject();
373 }
374 formatter.EndArray();
375
376 if (!do_write || plan.writes.empty()) {
377 formatter.AddField("write_status", do_write ? "not-needed" : "dry-run");
378 return absl::OkStatus();
379 }
380
381 const auto before_rom = rom->vector();
382 ScopedRomTransaction transaction(*rom);
384 Rom::SaveSettings save_settings;
385 save_settings.require_backup = true;
386 if (auto status = rom->SaveToFile(save_settings); !status.ok()) {
387 formatter.AddField("save_error", std::string(status.message()));
388 return status;
389 }
390 transaction.Commit();
391 formatter.AddField("save_status", "saved");
392
393 // Reopen the saved file: the list must read back exactly and no byte
394 // outside the planned writes may differ from the pre-edit ROM.
395 Rom reopened;
396 RETURN_IF_ERROR(reopened.LoadFromFile(rom->filename()));
397 ASSIGN_OR_RETURN(auto readback,
398 zelda3::ReadOverworldSpriteList(reopened, pointer_pc));
399 if (readback != new_bytes)
400 return absl::DataLossError("Saved sprite list does not read back");
401 const auto& saved = reopened.vector();
402 if (saved.size() != before_rom.size())
403 return absl::DataLossError("Saved ROM size changed");
404 int changed = 0;
405 for (size_t i = 0; i < saved.size(); ++i) {
406 if (saved[i] == before_rom[i])
407 continue;
408 ++changed;
409 bool planned = false;
410 for (const auto& write : plan.writes) {
411 planned |= static_cast<int>(i) >= write.address &&
412 static_cast<int>(i) <
413 write.address + static_cast<int>(write.bytes.size());
414 }
415 if (!planned)
416 return absl::DataLossError(
417 absl::StrFormat("Unplanned byte changed at PC $%06X", i));
418 }
419 formatter.AddField("verified_reload", true);
420 formatter.AddField("changed_bytes", changed);
421 return absl::OkStatus();
422}
423
424} // namespace handlers
425} // namespace cli
426} // 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
absl::Status LoadFromFile(const std::string &filename, const LoadOptions &options=LoadOptions::Defaults())
Definition rom.cc:491
auto filename() const
Definition rom.h:207
const auto & vector() const
Definition rom.h:205
absl::Status SaveToFile(const SaveSettings &settings)
Definition rom.cc:648
bool is_loaded() const
Definition rom.h:187
absl::Status ExecuteWithContext(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter, const resources::CommandInvocationContext &invocation_context) override
Execute with immutable, invocation-scoped ROM path identity.
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
std::string GetName() const override
Get the command name.
std::string GetUsage() const override
Get the command usage string.
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::Status RequireArgs(const std::vector< std::string > &required) const
Validate that required arguments are 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 BeginObject(const std::string &title="")
Start a JSON object or text section.
void EndObject()
End a JSON object or text section.
void AddField(const std::string &key, const std::string &value)
Add a key-value pair.
absl::Status Load(Rom *rom)
Load all overworld data from ROM.
Definition overworld.cc:136
auto overworld_map(int i) const
Definition overworld.h:737
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
void AddEntries(resources::OutputFormatter &formatter, const std::string &key, const std::vector< Entry > &entries)
absl::StatusOr< int > RequiredInt(const resources::ArgumentParser &parser, const std::string &name)
std::vector< Entry > Decode(const zelda3::OverworldSpriteBytes &bytes)
zelda3::OverworldSpriteBytes Encode(const std::vector< Entry > &entries)
bool IsOracleProjectRomPath(const std::filesystem::path &rom_path)
const char * SpritePhaseName(int phase)
std::vector< uint8_t > OverworldSpriteBytes
absl::StatusOr< OverworldSpriteListEditPlan > PlanOverworldSpriteListEdit(const Rom &rom, int state, int map, const OverworldSpriteBytes &replacement)
absl::StatusOr< OverworldSpriteBytes > ReadOverworldSpriteList(const Rom &rom, int pointer_address)
absl::Status ApplyOverworldSpriteSave(Rom &rom, const OverworldSpriteSavePlan &plan)
const char * OverworldSpriteEditStrategyName(OverworldSpriteEditStrategy s)
OverworldSpriteLayout GetOverworldSpriteLayout(const Rom &rom)
const char * ResolveSpriteName(uint16_t id)
Definition sprite.cc:287
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::optional< std::filesystem::path > active_rom_path
std::optional< std::filesystem::path > source_rom_path