yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
gfx_sheet_png_commands.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cctype>
5#include <filesystem>
6#include <optional>
7#include <string>
8#include <vector>
9
10#include "absl/status/status.h"
11#include "absl/strings/match.h"
12#include "absl/strings/numbers.h"
13#include "absl/strings/str_format.h"
14#include "absl/strings/str_join.h"
15#include "absl/strings/str_split.h"
16#include "absl/strings/strip.h"
18#include "core/project.h"
19#include "nlohmann/json.hpp"
20#include "rom/rom.h"
21#include "rom/rom_diff.h"
22#include "util/hex.h"
23#include "util/indexed_png.h"
24#include "util/macro.h"
25#include "zelda3/game_data.h"
29
30namespace yaze {
31namespace cli {
32namespace {
33
34absl::StatusOr<int> ParseNumber(absl::string_view text, const char* what) {
35 text = absl::StripAsciiWhitespace(text);
36 // yaze::util::ParseHexString instead of absl::SimpleHexAtoi: Ubuntu 22.04's
37 // system Abseil (20210324) predates SimpleHexAtoi.
38 const bool hex = absl::StartsWith(text, "0x") ||
39 absl::StartsWith(text, "0X") || absl::StartsWith(text, "$");
40 int value = 0;
41 const bool ok = hex ? yaze::util::ParseHexString(text, &value)
42 : absl::SimpleAtoi(text, &value);
43 if (!ok) {
44 return absl::InvalidArgumentError(
45 absl::StrFormat("%s '%s' is not a number", what, std::string(text)));
46 }
47 return value;
48}
49
50absl::StatusOr<uint16_t> ParseSheet(const resources::ArgumentParser& parser) {
51 auto text = parser.GetString("sheet");
52 if (!text.has_value()) {
53 return absl::InvalidArgumentError("--sheet is required");
54 }
55 ASSIGN_OR_RETURN(int sheet, ParseNumber(*text, "--sheet"));
56 if (sheet < 0 || sheet >= static_cast<int>(zelda3::kGfxSheetCount)) {
57 return absl::InvalidArgumentError("--sheet must be 0-222");
58 }
59 if (zelda3::GetGfxSheetStorageKind(static_cast<uint16_t>(sheet)) ==
61 return absl::UnimplementedError(
62 "2bpp sheets (113, 114, 218+) are not supported");
63 }
64 return static_cast<uint16_t>(sheet);
65}
66
67absl::StatusOr<int> ParseIntArg(const resources::ArgumentParser& parser,
68 const char* name, int fallback) {
69 auto text = parser.GetString(name);
70 if (!text.has_value()) {
71 return fallback;
72 }
73 return ParseNumber(*text, name);
74}
75
76absl::StatusOr<zelda3::SheetPalette> ResolvePalette(
77 Rom& rom, const resources::ArgumentParser& parser, int sheet = -1,
78 std::optional<zelda3::RoomSheetPaletteContext>* context = nullptr) {
79 const std::string spec = parser.GetString("palette").value_or("gray");
80 if (spec == "gray") {
82 }
83 if (absl::StartsWith(spec, "rgb:")) {
84 std::vector<std::string> parts = absl::StrSplit(spec.substr(4), ',');
85 if (parts.size() != 8) {
86 return absl::InvalidArgumentError("rgb: palettes need 8 colors");
87 }
89 for (int i = 0; i < 8; ++i) {
90 absl::string_view hex = absl::StripAsciiWhitespace(parts[i]);
91 absl::ConsumePrefix(&hex, "#");
92 uint32_t rgb = 0;
93 if (hex.size() != 6 ||
94 !std::all_of(hex.begin(), hex.end(),
95 [](unsigned char c) { return std::isxdigit(c) != 0; }) ||
96 !yaze::util::ParseHexString(hex, &rgb)) {
97 return absl::InvalidArgumentError("rgb: colors are #RRGGBB");
98 }
99 palette[i] = {static_cast<uint8_t>(rgb >> 16),
100 static_cast<uint8_t>(rgb >> 8), static_cast<uint8_t>(rgb)};
101 }
102 return palette;
103 }
104 if (absl::StartsWith(spec, "room-slot:")) {
105 const std::vector<std::string> parts = absl::StrSplit(spec, ':');
106 if (parts.size() != 4 || sheet < 0) {
107 return absl::InvalidArgumentError(
108 "room-slot:<room>:<slot>:<row> requires a single --sheet; use "
109 "gfx-room-export --out-dir for per-slot room palettes");
110 }
111 ASSIGN_OR_RETURN(const int room, ParseNumber(parts[1], "room"));
112 ASSIGN_OR_RETURN(const int slot, ParseNumber(parts[2], "slot"));
113 ASSIGN_OR_RETURN(const int row, ParseNumber(parts[3], "row"));
114 zelda3::GameData data;
115 zelda3::LoadOptions options;
116 options.load_graphics = false;
117 options.expand_rom = false;
118 RETURN_IF_ERROR(zelda3::LoadGameData(rom, data, options));
120 rom, data, room, slot, row));
121 if (resolved.background.sheets[slot] != sheet) {
122 return absl::InvalidArgumentError(absl::StrFormat(
123 "Room 0x%03X slot %d loads sheet 0x%02X, not --sheet 0x%02X", room,
124 slot, resolved.background.sheets[slot], sheet));
125 }
126 if (context != nullptr)
127 *context = resolved;
128 return resolved.colors;
129 }
130 const bool background = absl::StartsWith(spec, "room-bg:");
131 if (background || absl::StartsWith(spec, "room-sprite:")) {
132 std::vector<std::string> parts = absl::StrSplit(spec, ':');
133 if (parts.size() != 3) {
134 return absl::InvalidArgumentError(
135 "Room palettes are room-bg:<room>:<row> or room-sprite:<room>:<row>");
136 }
137 ASSIGN_OR_RETURN(int room, ParseNumber(parts[1], "room"));
138 ASSIGN_OR_RETURN(int row, ParseNumber(parts[2], "row"));
139 zelda3::GameData data;
140 zelda3::LoadOptions options;
141 options.load_graphics = false;
142 options.expand_rom = false;
143 RETURN_IF_ERROR(zelda3::LoadGameData(rom, data, options));
144 return background ? zelda3::RoomBackgroundSheetPalette(rom, data, room, row)
146 rom, data, room, row,
147 sheet >= 0 && zelda3::IsHighHalfSpriteSheet(sheet));
148 }
149 return absl::InvalidArgumentError("Unknown --palette " + spec);
150}
151
152std::string PaletteText(const zelda3::SheetPalette& palette) {
153 return absl::StrJoin(palette, ",", [](std::string* out, const auto& color) {
154 out->append(absl::StrFormat("#%02X%02X%02X", color[0], color[1], color[2]));
155 });
156}
157
158nlohmann::json PaletteContextJson(
159 const zelda3::RoomSheetPaletteContext& context) {
160 using Source = zelda3::RoomMainBlocksetSource;
161 const char* main_source = "zero_fallback";
162 switch (context.main.source) {
163 case Source::kExplicitPreview:
164 main_source = "explicit_preview";
165 break;
166 case Source::kDirectDefault:
167 main_source = "direct_default";
168 break;
169 case Source::kInferredDefault:
170 main_source = "inferred_default";
171 break;
172 case Source::kHeaderFallback:
173 main_source = "header_fallback";
174 break;
175 case Source::kZeroFallback:
176 break;
177 }
178 using EntranceSource = zelda3::RoomEntranceSource;
179 const char* entrance_source = "none";
180 switch (context.entrance.source) {
181 case EntranceSource::kDirect:
182 entrance_source = "direct";
183 break;
184 case EntranceSource::kDungeonMap:
185 entrance_source = "dungeon_map";
186 break;
187 case EntranceSource::kNeighbour:
188 entrance_source = "neighbour";
189 break;
190 case EntranceSource::kNone:
191 break;
192 }
193 return {{"room", context.background.room},
194 {"slot", context.slot},
195 {"sheet", context.background.sheets[context.slot]},
196 {"main_blockset", context.main.main_blockset},
197 {"main_source", main_source},
198 {"room_blockset", context.background.header_blockset},
199 {"sheet_source", context.background.from_room_blockset[context.slot]
200 ? "room_blockset"
201 : "main_blockset"},
202 {"entrance_id", context.entrance.entrance_id},
203 {"entrance_source", entrance_source},
204 {"entrance_via_room", context.entrance.via_room},
205 {"palette_row", context.row},
206 {"palette_half", context.palette_offset == 8 ? "right" : "left"},
207 {"raw_index_to_cgram", context.cgram_indices},
208 {"colors_rgb", context.colors},
209 {"transparent_index", 0},
210 {"animation_overlay_included", false}};
211}
212
213// Check all output aliases before the first write, including symlinks and hard
214// links. PNG/JSON destinations must not destroy each other or the source ROM.
215absl::Status ValidateExportPaths(const Rom& rom,
216 const std::vector<std::string>& outputs) {
217 std::vector<std::string> seen;
218 if (!rom.filename().empty())
219 seen.push_back(rom.filename());
220 for (const auto& path : outputs) {
221 if (path.empty())
222 return absl::InvalidArgumentError("Empty export path");
223 std::error_code ec;
224 const auto canonical = std::filesystem::weakly_canonical(path, ec);
225 if (ec)
226 return absl::InvalidArgumentError("Invalid export path: " + path);
227 for (const auto& other : seen) {
228 const bool equivalent = std::filesystem::equivalent(path, other, ec);
229 ec.clear();
230 const auto other_canonical = std::filesystem::weakly_canonical(other, ec);
231 if (equivalent || (!ec && canonical == other_canonical)) {
232 return absl::InvalidArgumentError(
233 "Export destinations must differ from each other and the input "
234 "ROM");
235 }
236 }
237 seen.push_back(path);
238 }
239 return absl::OkStatus();
240}
241
242absl::Status WriteJson(const std::string& path, const nlohmann::json& value) {
243 const std::string text = value.dump(2) + "\n";
244 return util::WriteBinaryFile(path, {text.begin(), text.end()});
245}
246
247std::string RangeText(const std::vector<std::pair<int, int>>& ranges) {
248 return absl::StrJoin(ranges, " ", [](std::string* out, const auto& range) {
249 out->append(
250 absl::StrFormat("0x%03X-0x%03X", range.first, range.second - 1));
251 });
252}
253
260
261absl::StatusOr<RoomContext> LoadRoomContext(
262 Rom& rom, const resources::ArgumentParser& parser) {
263 auto room_text = parser.GetString("room");
264 if (!room_text.has_value()) {
265 return absl::InvalidArgumentError("--room is required");
266 }
267 ASSIGN_OR_RETURN(const int room, ParseNumber(*room_text, "--room"));
268 if (room < 0 || room >= zelda3::kNumDungeonRooms) {
269 return absl::InvalidArgumentError("--room must be 0x000-0x127");
270 }
271 RoomContext context;
272 ASSIGN_OR_RETURN(context.row, ParseIntArg(parser, "row", 2));
273 zelda3::LoadOptions options;
274 options.load_graphics = false;
275 options.expand_rom = false;
276 RETURN_IF_ERROR(zelda3::LoadGameData(rom, context.data, options));
277 const auto rooms = zelda3::CollectRoomGfx(rom);
278 const auto header = rooms.find(room);
279 if (header == rooms.end()) {
280 return absl::DataLossError(
281 absl::StrFormat("Room 0x%03X header is unreadable", room));
282 }
283 context.set = zelda3::ResolveRoomBackgroundSet(context.data, room,
284 header->second.blockset);
285 if (parser.GetString("palette").has_value()) {
286 ASSIGN_OR_RETURN(context.palette, ResolvePalette(rom, parser));
287 } else {
289 zelda3::RoomBackgroundSheetPalette(rom, context.data, room,
290 context.row));
291 }
292 return context;
293}
294
295absl::Status ExportRoomBundle(Rom& rom, RoomContext& context,
296 const std::string& directory,
297 resources::OutputFormatter& formatter) {
298 if (directory.empty())
299 return absl::InvalidArgumentError("Empty --out-dir");
300 // Resolve and encode every supported slot before creating any output. Each
301 // PNG keeps raw indices 0-7 while carrying that slot's display palette.
302 std::vector<std::pair<std::string, std::vector<uint8_t>>> files;
303 nlohmann::json manifest = {{"schema_version", 1},
304 {"room", context.set.room},
305 {"palette_row", context.row},
306 {"source_rom", rom.filename()},
307 {"slots", nlohmann::json::array()}};
308 for (int slot = 0; slot < 8; ++slot) {
310 const auto palette_context,
311 zelda3::ResolveRoomSheetPalette(rom, context.data, context.set.room,
312 slot, context.row));
313 auto entry = PaletteContextJson(palette_context);
314 const uint16_t sheet = context.set.sheets[slot];
315 if (sheet >= zelda3::kGfxSheetCount ||
318 entry["status"] = "unsupported_sheet_format";
319 } else {
320 ASSIGN_OR_RETURN(const auto raw, zelda3::ReadGfxSheetData(rom, sheet));
322 auto png, zelda3::ExportSheetBlocksPng(raw, palette_context.colors));
323 const std::string name =
324 absl::StrFormat("slot-%d-sheet-%02X.png", slot, sheet);
325 entry["status"] = "exported";
326 entry["png"] = name;
327 entry["palette_spec"] = absl::StrFormat(
328 "room-slot:0x%03X:%d:%d", context.set.room, slot, context.row);
329 files.emplace_back(name, std::move(png));
330 }
331 manifest["slots"].push_back(std::move(entry));
332 }
333 std::error_code ec;
334 if (!std::filesystem::create_directory(directory, ec)) {
335 return absl::FailedPreconditionError(
336 "--out-dir must be a new directory in an existing parent: " +
337 directory + (ec ? " (" + ec.message() + ")" : ""));
338 }
339 auto write = [&]() -> absl::Status {
340 for (const auto& [name, png] : files) {
342 (std::filesystem::path(directory) / name).string(), png));
343 }
344 return WriteJson(
345 (std::filesystem::path(directory) / "manifest.json").string(),
346 manifest);
347 };
348 const absl::Status status = write();
349 if (!status.ok()) {
350 // This invocation created the directory; never clean up a preexisting one.
351 std::filesystem::remove_all(directory, ec);
352 return status;
353 }
354 formatter.AddField("out_dir", directory);
355 formatter.AddRawJsonField("manifest", manifest.dump());
356 return absl::OkStatus();
357}
358
359std::string HexIds(const std::vector<int>& ids) {
360 return absl::StrJoin(ids, " ", [](std::string* out, int id) {
361 out->append(absl::StrFormat("%03X", id));
362 });
363}
364
365// Which CGRAM columns a room-sprite: palette read for this sheet, or nullopt
366// for other palette specs.
367std::optional<std::string> SpritePaletteHalf(
368 const resources::ArgumentParser& parser, uint16_t sheet) {
369 if (!absl::StartsWith(parser.GetString("palette").value_or(""),
370 "room-sprite:")) {
371 return std::nullopt;
372 }
373 return zelda3::IsHighHalfSpriteSheet(sheet) ? "high (columns 8-15)"
374 : "low (columns 0-7)";
375}
376
377// Every group, overworld area and room that loads `sheet`, so a dry run shows
378// who else sees the edit. Empty lists are left out.
379absl::StatusOr<nlohmann::json> SheetSharedBy(
380 Rom& rom, uint16_t sheet, const zelda3::GfxSheetInventoryOptions& options,
381 bool* reserved) {
382 const auto areas = zelda3::CollectOverworldAreaGfx(rom);
383 const auto rooms = zelda3::CollectRoomGfx(rom);
384 ASSIGN_OR_RETURN(const auto inventory,
385 zelda3::BuildGfxSheetInventory(rom, areas, rooms, options));
386 if (sheet >= inventory.sheets.size()) {
387 return absl::OutOfRangeError(absl::StrFormat("No sheet 0x%02X", sheet));
388 }
389 const auto& entry = inventory.sheets[sheet];
390 *reserved = entry.reserved;
391 nlohmann::json shared = nlohmann::json::object();
392 const std::pair<const char*, const std::vector<int>*> lists[] = {
393 {"main_blocksets", &entry.usage.main_blocksets},
394 {"room_blocksets", &entry.usage.room_blocksets},
395 {"ow_areas_static", &entry.usage.ow_areas_static},
396 {"spritesets", &entry.usage.spritesets},
397 {"ow_areas_sprite", &entry.usage.ow_areas_sprite},
398 {"rooms_sprite", &entry.usage.rooms_sprite}};
399 for (const auto& [key, ids] : lists) {
400 if (!ids->empty())
401 shared[key] = *ids;
402 }
403 return shared;
404}
405
406// These commands use the US graphics pointer tables; on a Japanese ROM they
407// would read and write the wrong sheets.
408absl::Status RequireUsGfxTables(const Rom& rom) {
410 return absl::FailedPreconditionError(
411 "This ROM uses the Japanese graphics pointer tables (header byte "
412 "$7FD9 = 0); the gfx commands support US-table ROMs only");
413 }
414 return absl::OkStatus();
415}
416
417// The write policy (graphics free space, reserved sheets, ASM-owned bytes)
418// comes only from --project-context. --project and --manifest look right but
419// were silently ignored, so a grown sheet was refused with "no graphics free
420// space is registered" even though the manifest registers some.
422 for (const char* flag : {"project", "manifest"}) {
423 if (parser.GetString(flag).has_value()) {
424 return absl::InvalidArgumentError(absl::StrFormat(
425 "--%s is not read here; pass --project-context <path.yaze> so the "
426 "project's hack manifest (graphics_sheet_regions) sets where a "
427 "grown sheet may move",
428 flag));
429 }
430 }
431 return absl::OkStatus();
432}
433
434constexpr char kNoProjectHint[] =
435 "no --project-context given, so no hack manifest free space was used";
436
437} // namespace
438
440 const resources::ArgumentParser& parser) {
441 return parser.RequireArgs({"sheet", "png"});
442}
443
445 Rom* rom, const resources::ArgumentParser& parser,
446 resources::OutputFormatter& formatter) {
447 RETURN_IF_ERROR(RequireUsGfxTables(*rom));
448 ASSIGN_OR_RETURN(const uint16_t sheet, ParseSheet(parser));
449 ASSIGN_OR_RETURN(const int block, ParseIntArg(parser, "block", 0));
450 ASSIGN_OR_RETURN(const int count, ParseIntArg(parser, "count", 16 - block));
451 std::optional<zelda3::RoomSheetPaletteContext> context;
452 ASSIGN_OR_RETURN(const auto palette,
453 ResolvePalette(*rom, parser, sheet, &context));
454 ASSIGN_OR_RETURN(const auto data, zelda3::ReadGfxSheetData(*rom, sheet));
455 ASSIGN_OR_RETURN(const auto png,
456 zelda3::ExportSheetBlocksPng(data, palette, block, count));
457 const std::string path = *parser.GetString("png");
458 const auto json_path = parser.GetString("json");
459 std::vector<std::string> outputs = {path};
460 if (json_path.has_value())
461 outputs.push_back(*json_path);
462 RETURN_IF_ERROR(ValidateExportPaths(*rom, outputs));
463 nlohmann::json metadata = {{"sheet", sheet}, {"png", path},
464 {"first_block", block}, {"block_count", count},
465 {"colors_rgb", palette}, {"transparent_index", 0}};
466 if (context.has_value())
467 metadata["room_context"] = PaletteContextJson(*context);
469 if (json_path.has_value())
470 RETURN_IF_ERROR(WriteJson(*json_path, metadata));
471
472 formatter.AddField("sheet", absl::StrFormat("0x%02X", sheet));
473 formatter.AddField("png", path);
474 formatter.AddField("blocks",
475 absl::StrFormat("%d-%d", block, block + count - 1));
476 formatter.AddField("width", std::min(count, 8) * 16);
477 formatter.AddField("height", (count + 7) / 8 * 16);
478 formatter.AddField("palette", PaletteText(palette));
479 if (const auto half = SpritePaletteHalf(parser, sheet); half.has_value())
480 formatter.AddField("palette_half", *half);
481 if (context.has_value()) {
482 formatter.AddRawJsonField("room_context", metadata["room_context"].dump());
483 }
484 return absl::OkStatus();
485}
486
488 const resources::ArgumentParser& parser) {
489 RETURN_IF_ERROR(parser.RequireArgs({"sheet", "png"}));
490 RETURN_IF_ERROR(RejectUnreadProjectFlags(parser));
491 const bool dry_run = parser.HasFlag("dry-run");
492 const bool write = parser.HasFlag("write");
493 if (dry_run == write) {
494 return absl::InvalidArgumentError("Pass exactly one of --dry-run, --write");
495 }
496 if (write && !parser.GetString("out").has_value()) {
497 return absl::InvalidArgumentError(
498 "--write needs --out <rom file>; the input ROM is never overwritten");
499 }
500 return absl::OkStatus();
501}
502
504 Rom* rom, const resources::ArgumentParser& parser,
505 resources::OutputFormatter& formatter) {
506 if (parser.HasFlag("write") && project_ != nullptr &&
509 return absl::PermissionDeniedError(
510 "Graphics import is disabled by the project's read-only policy");
511 }
512 RETURN_IF_ERROR(RequireUsGfxTables(*rom));
513 ASSIGN_OR_RETURN(const uint16_t sheet, ParseSheet(parser));
514 ASSIGN_OR_RETURN(const int block, ParseIntArg(parser, "block", 0));
515 std::optional<zelda3::RoomSheetPaletteContext> context;
516 ASSIGN_OR_RETURN(const auto palette,
517 ResolvePalette(*rom, parser, sheet, &context));
518 if (context.has_value()) {
519 formatter.AddRawJsonField("room_context",
520 PaletteContextJson(*context).dump());
521 }
522 ASSIGN_OR_RETURN(const auto current, zelda3::ReadGfxSheetData(*rom, sheet));
523 const std::string png_path = *parser.GetString("png");
524 const auto diff_png = parser.GetString("diff-png");
525 std::vector<std::string> paths = {png_path};
526 if (project_ != nullptr && !project_->filepath.empty())
527 paths.push_back(project_->filepath);
528 if (parser.HasFlag("write"))
529 paths.push_back(*parser.GetString("out"));
530 if (diff_png.has_value())
531 paths.push_back(*diff_png);
532 RETURN_IF_ERROR(ValidateExportPaths(*rom, paths));
533 ASSIGN_OR_RETURN(const auto png_bytes, util::ReadBinaryFile(png_path));
534 ASSIGN_OR_RETURN(const auto png, util::DecodePng(png_bytes));
535 ASSIGN_OR_RETURN(const auto imported,
536 zelda3::ImportSheetBlocksPng(current, png, block, palette));
537 if (diff_png.has_value()) {
539 const auto diff,
540 zelda3::ExportSheetDiffPng(current, imported.snes_3bpp, palette));
541 RETURN_IF_ERROR(util::WriteBinaryFile(*diff_png, diff));
542 }
543 bool reserved = false;
544 const auto shared_by = SheetSharedBy(
545 *rom, sheet, core::BuildGfxSheetInventoryOptions(project_), &reserved);
546
547 // Run the real writer on a copy of the ROM buffer; the diff is exactly
548 // what --write would change.
550 if (project_ != nullptr) {
552 }
553 Rom simulated = *rom;
554 const auto written =
555 zelda3::WriteGfxSheet(simulated, sheet, imported.snes_3bpp, policy);
556
557 formatter.AddField("sheet", absl::StrFormat("0x%02X", sheet));
558 formatter.AddField("png", png_path);
559 formatter.AddField("changed_blocks",
560 absl::StrJoin(imported.changed_blocks, " "));
561 formatter.AddField("changed_tiles",
562 static_cast<int>(imported.changed_tiles.size()));
563 formatter.AddField("sheet_byte_ranges", RangeText(imported.changed_ranges));
564 if (const auto half = SpritePaletteHalf(parser, sheet); half.has_value())
565 formatter.AddField("palette_half", *half);
566 if (shared_by.ok()) {
567 formatter.AddRawJsonField("shared_by", shared_by->dump());
568 } else {
569 formatter.AddField("shared_by_error",
570 std::string(shared_by.status().message()));
571 }
572 if (reserved) {
573 formatter.AddField("reserved_sheet",
574 "The project reserves this sheet; check its owner "
575 "before writing");
576 }
577 if (diff_png.has_value())
578 formatter.AddField("diff_png", *diff_png);
579 if (!written.ok()) {
580 // The preview still stands; say why the write would be refused, and fail
581 // the command so scripts see the refusal in the exit code.
582 formatter.AddField("write_status", std::string(written.status().message()));
583 if (project_ == nullptr) {
584 formatter.AddField("hint", kNoProjectHint);
585 }
586 if (parser.HasFlag("dry-run"))
587 formatter.AddField("mode", "dry-run");
588 return written.status();
589 }
590 formatter.AddField("write_status", "ok");
591 const auto rom_diff =
592 rom::ComputeDiffRanges(rom->vector(), simulated.vector());
593 formatter.AddField("placement",
594 written->placement == zelda3::GfxSheetPlacement::kRelocated
595 ? "relocated"
596 : "in place");
597 formatter.AddField("rom_pc", absl::StrFormat("0x%06X", written->new_pc));
598 formatter.AddField("stored_bytes",
599 absl::StrFormat("%zu -> %zu", written->old_stored_size,
600 written->new_stored_size));
601 formatter.AddField("rom_bytes_changed",
602 static_cast<int>(rom_diff.total_bytes_changed));
603 formatter.AddField(
604 "rom_ranges",
605 absl::StrJoin(rom_diff.ranges, " ",
606 [](std::string* out, const auto& range) {
607 out->append(absl::StrFormat("0x%06X-0x%06X", range.first,
608 range.second - 1));
609 }));
610
611 if (parser.HasFlag("dry-run")) {
612 formatter.AddField("mode", "dry-run");
613 return absl::OkStatus();
614 }
615 const std::string out = *parser.GetString("out");
616 std::error_code ec;
617 if (!rom->filename().empty() &&
618 std::filesystem::equivalent(out, rom->filename(), ec)) {
619 return absl::InvalidArgumentError(
620 "--out is the input ROM; write to a new file");
621 }
623 formatter.AddField("mode", "write");
624 formatter.AddField("out", out);
625 return absl::OkStatus();
626}
627
629 const resources::ArgumentParser& parser) {
630 RETURN_IF_ERROR(parser.RequireArgs({"room"}));
631 const bool bundle = parser.GetString("out-dir").has_value();
632 if (bundle == parser.GetString("png").has_value()) {
633 return absl::InvalidArgumentError("Pass exactly one of --png or --out-dir");
634 }
635 if (bundle && (parser.GetString("palette").has_value() ||
636 parser.GetString("json").has_value())) {
637 return absl::InvalidArgumentError(
638 "--out-dir resolves each slot's palette and writes manifest.json; "
639 "do not pass --palette or --json");
640 }
641 return absl::OkStatus();
642}
643
645 Rom* rom, const resources::ArgumentParser& parser,
646 resources::OutputFormatter& formatter) {
647 RETURN_IF_ERROR(RequireUsGfxTables(*rom));
648 ASSIGN_OR_RETURN(auto context, LoadRoomContext(*rom, parser));
649 if (const auto directory = parser.GetString("out-dir");
650 directory.has_value()) {
651 return ExportRoomBundle(*rom, context, *directory, formatter);
652 }
653 const auto& set = context.set;
654 ASSIGN_OR_RETURN(const auto png,
655 zelda3::ExportRoomBackgroundPng(*rom, set, context.palette));
656 const std::string png_path = *parser.GetString("png");
657 std::vector<std::string> outputs = {png_path};
658 if (const auto json_path = parser.GetString("json"); json_path.has_value()) {
659 outputs.push_back(*json_path);
660 }
661 RETURN_IF_ERROR(ValidateExportPaths(*rom, outputs));
663
664 // Who else sees these sheets: an edit changes every one of these rooms.
665 const auto all_sets =
667 std::vector<int> same_main, same_room_blockset;
668 for (const auto& other : all_sets) {
669 if (other.main_blockset == set.main_blockset) {
670 same_main.push_back(other.room);
671 }
672 if (other.header_blockset == set.header_blockset) {
673 same_room_blockset.push_back(other.room);
674 }
675 }
676 nlohmann::json slots = nlohmann::json::array();
677 for (int slot = 0; slot < 8; ++slot) {
678 std::vector<int> rooms;
679 for (const auto& other : all_sets) {
680 if (std::find(other.sheets.begin(), other.sheets.end(),
681 set.sheets[slot]) != other.sheets.end()) {
682 rooms.push_back(other.room);
683 }
684 }
685 slots.push_back({{"slot", slot},
686 {"sheet", absl::StrFormat("0x%02X", set.sheets[slot])},
687 {"source", set.from_room_blockset[slot] ? "room_blockset"
688 : "main_blockset"},
689 {"png_y", slot * 32},
690 {"rooms_using_sheet", rooms}});
691 }
692 const std::string rom_path = rom->filename();
693 const std::string import_command = absl::StrFormat(
694 "z3ed gfx-room-import --rom %s --room 0x%03X --png %s --row %d --dry-run",
695 rom_path, set.room, png_path, context.row);
696 nlohmann::json sidecar = {
697 {"room", absl::StrFormat("0x%03X", set.room)},
698 {"main_blockset", set.main_blockset},
699 {"room_blockset", set.header_blockset},
700 {"palette_row", context.row},
701 {"palette", PaletteText(context.palette)},
702 {"palette_mode", "uniform_palette_all_slots"},
703 {"png", png_path},
704 {"slots", slots},
705 {"rooms_with_same_main_blockset", same_main},
706 {"rooms_with_same_room_blockset", same_room_blockset},
707 {"reimport", import_command},
708 };
709 if (auto json_path = parser.GetString("json"); json_path.has_value()) {
710 const std::string text = sidecar.dump(1) + "\n";
712 *json_path, std::vector<uint8_t>(text.begin(), text.end())));
713 }
714 for (const auto& [key, value] : sidecar.items()) {
715 formatter.AddRawJsonField(key, value.dump());
716 }
717 return absl::OkStatus();
718}
719
721 const resources::ArgumentParser& parser) {
722 RETURN_IF_ERROR(parser.RequireArgs({"room", "png"}));
723 RETURN_IF_ERROR(RejectUnreadProjectFlags(parser));
724 const bool dry_run = parser.HasFlag("dry-run");
725 const bool write = parser.HasFlag("write");
726 if (dry_run == write) {
727 return absl::InvalidArgumentError("Pass exactly one of --dry-run, --write");
728 }
729 if (write && !parser.GetString("out").has_value()) {
730 return absl::InvalidArgumentError(
731 "--write needs --out <rom file>; the input ROM is never overwritten");
732 }
733 return absl::OkStatus();
734}
735
737 Rom* rom, const resources::ArgumentParser& parser,
738 resources::OutputFormatter& formatter) {
739 if (parser.HasFlag("write") && project_ != nullptr &&
742 return absl::PermissionDeniedError(
743 "Graphics import is disabled by the project's read-only policy");
744 }
745 RETURN_IF_ERROR(RequireUsGfxTables(*rom));
746 ASSIGN_OR_RETURN(auto context, LoadRoomContext(*rom, parser));
747 const std::string png_path = *parser.GetString("png");
748 ASSIGN_OR_RETURN(const auto png_bytes, util::ReadBinaryFile(png_path));
749 ASSIGN_OR_RETURN(const auto png, util::DecodePng(png_bytes));
751 const auto imports,
752 zelda3::ImportRoomBackgroundPng(*rom, context.set, png, context.palette));
753
755 if (project_ != nullptr) {
757 }
758 Rom simulated = *rom;
759 const auto all_sets =
761 nlohmann::json sheets = nlohmann::json::array();
762 std::string refusal;
763 std::vector<int> affected_rooms;
764 for (const auto& import : imports) {
765 auto written = zelda3::WriteGfxSheet(simulated, import.sheet,
766 import.result.snes_3bpp, policy);
767 nlohmann::json entry = {
768 {"sheet", absl::StrFormat("0x%02X", import.sheet)},
769 {"slots", import.slots},
770 {"changed_blocks", import.result.changed_blocks},
771 {"changed_tiles", import.result.changed_tiles.size()},
772 };
773 if (written.ok()) {
774 entry["placement"] =
775 written->placement == zelda3::GfxSheetPlacement::kRelocated
776 ? "relocated"
777 : "in place";
778 entry["stored_bytes"] = absl::StrFormat(
779 "%zu -> %zu", written->old_stored_size, written->new_stored_size);
780 } else {
781 entry["write_status"] = std::string(written.status().message());
782 if (refusal.empty()) {
783 refusal = entry["write_status"];
784 }
785 }
786 for (const auto& other : all_sets) {
787 if (std::find(other.sheets.begin(), other.sheets.end(), import.sheet) !=
788 other.sheets.end()) {
789 affected_rooms.push_back(other.room);
790 }
791 }
792 sheets.push_back(std::move(entry));
793 }
794 std::sort(affected_rooms.begin(), affected_rooms.end());
795 affected_rooms.erase(
796 std::unique(affected_rooms.begin(), affected_rooms.end()),
797 affected_rooms.end());
798
799 formatter.AddField("room", absl::StrFormat("0x%03X", context.set.room));
800 formatter.AddRawJsonField("sheets", sheets.dump());
801 formatter.AddField("rooms_affected", HexIds(affected_rooms));
802 formatter.AddField("write_status", refusal.empty() ? "ok" : refusal);
803 if (!refusal.empty() && project_ == nullptr) {
804 formatter.AddField("hint", kNoProjectHint);
805 }
806 const auto rom_diff =
807 rom::ComputeDiffRanges(rom->vector(), simulated.vector());
808 formatter.AddField("rom_bytes_changed",
809 static_cast<int>(rom_diff.total_bytes_changed));
810 if (parser.HasFlag("dry-run"))
811 formatter.AddField("mode", "dry-run");
812 // A dry run fails like the write would, so scripts see the refusal.
813 if (!refusal.empty()) {
814 return absl::FailedPreconditionError(refusal);
815 }
816 if (parser.HasFlag("dry-run"))
817 return absl::OkStatus();
818 const std::string out = *parser.GetString("out");
819 std::error_code ec;
820 if (!rom->filename().empty() &&
821 std::filesystem::equivalent(out, rom->filename(), ec)) {
822 return absl::InvalidArgumentError(
823 "--out is the input ROM; write to a new file");
824 }
826 formatter.AddField("mode", "write");
827 formatter.AddField("out", out);
828 return absl::OkStatus();
829}
830
831} // namespace cli
832} // 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:52
auto filename() const
Definition rom.h:219
const auto & vector() const
Definition rom.h:217
auto data() const
Definition rom.h:213
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
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.
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
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.
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.
Utility for consistent output formatting across commands.
void AddRawJsonField(const std::string &key, const std::string &json)
Add a field whose value is already-serialized JSON (numbers, nested arrays, null)....
void AddField(const std::string &key, const std::string &value)
Add a key-value pair.
Which graphics sheets exist, which tables use them, and which 16x16 blocks are free.
3bpp graphics sheets to and from indexed PNGs.
Locates graphics sheets in the ROM and writes edited sheets back.
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
absl::Status ExportRoomBundle(Rom &rom, RoomContext &context, const std::string &directory, resources::OutputFormatter &formatter)
absl::Status RejectUnreadProjectFlags(const resources::ArgumentParser &parser)
std::string RangeText(const std::vector< std::pair< int, int > > &ranges)
std::string PaletteText(const zelda3::SheetPalette &palette)
absl::StatusOr< zelda3::SheetPalette > ResolvePalette(Rom &rom, const resources::ArgumentParser &parser, int sheet=-1, std::optional< zelda3::RoomSheetPaletteContext > *context=nullptr)
absl::StatusOr< uint16_t > ParseSheet(const resources::ArgumentParser &parser)
absl::StatusOr< nlohmann::json > SheetSharedBy(Rom &rom, uint16_t sheet, const zelda3::GfxSheetInventoryOptions &options, bool *reserved)
absl::Status WriteJson(const std::string &path, const nlohmann::json &value)
nlohmann::json PaletteContextJson(const zelda3::RoomSheetPaletteContext &context)
absl::StatusOr< int > ParseIntArg(const resources::ArgumentParser &parser, const char *name, int fallback)
absl::StatusOr< RoomContext > LoadRoomContext(Rom &rom, const resources::ArgumentParser &parser)
absl::StatusOr< int > ParseNumber(absl::string_view text, const char *what)
absl::Status ValidateExportPaths(const Rom &rom, const std::vector< std::string > &outputs)
std::optional< std::string > SpritePaletteHalf(const resources::ArgumentParser &parser, uint16_t sheet)
absl::StatusOr< zelda3::GfxSheetWritePolicy > BuildGfxSheetWritePolicy(const HackManifest &manifest)
zelda3::GfxSheetInventoryOptions BuildGfxSheetInventoryOptions(const project::YazeProject *project)
DiffSummary ComputeDiffRanges(const std::vector< uint8_t > &before, const std::vector< uint8_t > &after)
Definition rom_diff.cc:11
absl::Status WriteBinaryFile(const std::string &path, const std::vector< uint8_t > &bytes)
absl::StatusOr< std::vector< uint8_t > > ReadBinaryFile(const std::string &path)
absl::StatusOr< PngImage > DecodePng(const std::vector< uint8_t > &bytes)
bool ParseHexString(absl::string_view str, uint64_t *out)
Definition hex.cc:133
constexpr int kNumDungeonRooms
std::vector< RoomBackgroundSet > ResolveAllRoomBackgroundSets(const Rom &rom, const GameData &data)
All 296 rooms' background sets (header bytes read from the ROM).
absl::StatusOr< SheetPalette > RoomSpriteSheetPalette(Rom &rom, GameData &data, int room, int row, bool high_half)
absl::StatusOr< RoomSheetPaletteContext > ResolveRoomSheetPalette(Rom &rom, GameData &data, int room, int slot, int row)
absl::StatusOr< std::vector< uint8_t > > ExportRoomBackgroundPng(const Rom &rom, const RoomBackgroundSet &set, const SheetPalette &palette)
absl::StatusOr< SheetImportResult > ImportSheetBlocksPng(const std::vector< uint8_t > &snes_3bpp, const util::PngImage &png, int first_block, const SheetPalette &palette)
Places a PNG's 16x16 blocks over a sheet from first_block.
absl::Status LoadGameData(Rom &rom, GameData &data, const LoadOptions &options)
Loads all Zelda3-specific game data from a generic ROM.
Definition game_data.cc:123
std::map< int, OverworldAreaGfxInfo > CollectOverworldAreaGfx(Rom &rom, GameData *game_data)
RoomBackgroundSet ResolveRoomBackgroundSet(const GameData &data, int room, uint8_t header_blockset)
absl::StatusOr< GfxSheetInventory > BuildGfxSheetInventory(const Rom &rom, const std::map< int, OverworldAreaGfxInfo > &areas, const std::map< int, RoomGfxInfo > &rooms, const GfxSheetInventoryOptions &options)
bool IsHighHalfSpriteSheet(int sheet)
std::map< int, RoomGfxInfo > CollectRoomGfx(const Rom &rom)
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).
absl::StatusOr< GfxSheetWriteResult > WriteGfxSheet(Rom &rom, uint16_t sheet_id, const std::vector< uint8_t > &snes_3bpp, const GfxSheetWritePolicy &policy, const GfxSheetPointerTables &tables)
Writes one edited 3bpp sheet to the ROM buffer.
GfxSheetStorageKind GetGfxSheetStorageKind(uint16_t sheet_id)
constexpr size_t kGfxSheetCount
bool UsesJapaneseGfxSheetTables(const Rom &rom)
std::array< SheetColor, 8 > SheetPalette
Colors for 3bpp indices 0-7. Index 0 is exported as transparent.
absl::StatusOr< std::vector< uint8_t > > ExportSheetBlocksPng(const std::vector< uint8_t > &snes_3bpp, const SheetPalette &palette, int first_block, int block_count)
SheetPalette GrayscaleSheetPalette()
absl::StatusOr< SheetPalette > RoomBackgroundSheetPalette(Rom &rom, GameData &data, int room, int row)
absl::StatusOr< std::vector< uint8_t > > ExportSheetDiffPng(const std::vector< uint8_t > &before_3bpp, const std::vector< uint8_t > &after_3bpp, const SheetPalette &palette)
absl::StatusOr< std::vector< RoomSheetImport > > ImportRoomBackgroundPng(const Rom &rom, const RoomBackgroundSet &set, const util::PngImage &png, const SheetPalette &palette)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
RomWritePolicy write_policy
Definition project.h:229
Rules a sheet write must follow.
std::array< uint8_t, 8 > sheets
std::array< bool, 8 > from_room_blockset
std::array< uint8_t, 8 > cgram_indices
RoomMainBlocksetResolution main