yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_edit_commands.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cstdint>
5#include <limits>
6#include <optional>
7#include <string>
8#include <utility>
9#include <vector>
10
11#include "absl/strings/numbers.h"
12#include "absl/strings/str_format.h"
13#include "absl/strings/str_split.h"
15#include "cli/util/hex_util.h"
17#include "core/hack_manifest.h"
18#include "core/project.h"
19#include "rom/rom.h"
20#include "rom/snes.h"
21#include "rom/transaction.h"
22#include "rom/write_fence.h"
23#include "util/macro.h"
26#include "zelda3/dungeon/room.h"
29#include "zelda3/game_data.h"
32
33namespace yaze {
34namespace cli {
35namespace handlers {
36
38
39namespace {
40
41absl::StatusOr<std::string> GetRequiredString(
42 const resources::ArgumentParser& parser, const char* name) {
43 auto value = parser.GetString(name);
44 if (!value.has_value()) {
45 return absl::InvalidArgumentError(
46 absl::StrFormat("Missing required argument '--%s'", name));
47 }
48 return *value;
49}
50
51absl::StatusOr<int> GetRequiredInt(const resources::ArgumentParser& parser,
52 const char* name) {
53 auto parsed = parser.GetInt(name);
54 if (!parsed.ok()) {
55 return parsed.status();
56 }
57 return parsed.value();
58}
59
60absl::StatusOr<int> GetOptionalInt(const resources::ArgumentParser& parser,
61 const char* name, int default_value) {
62 if (!parser.GetString(name).has_value()) {
63 return default_value;
64 }
65
66 auto parsed = parser.GetInt(name);
67 if (!parsed.ok()) {
68 return parsed.status();
69 }
70 return parsed.value();
71}
72
73absl::Status ValidateRoomId(int room_id) {
74 if (room_id < 0 || room_id >= zelda3::kNumberOfRooms) {
75 return absl::InvalidArgumentError(
76 absl::StrFormat("Room ID out of range: 0x%X (expected 0x00-0x%02X)",
77 room_id, zelda3::kNumberOfRooms - 1));
78 }
79 return absl::OkStatus();
80}
81
82absl::Status ValidateSpriteCoord(int value, char axis) {
83 if (value < 0 || value > 31) {
84 return absl::InvalidArgumentError(
85 absl::StrFormat("%c must be 0-31 (5-bit tile coord)", axis));
86 }
87 return absl::OkStatus();
88}
89
90absl::Status SaveRomWithBackup(Rom* rom,
91 resources::OutputFormatter& formatter) {
92 Rom::SaveSettings save_settings;
93 save_settings.require_backup = true;
94 auto disk_status = rom->SaveToFile(save_settings);
95 if (!disk_status.ok()) {
96 formatter.AddField("save_error", std::string(disk_status.message()));
97 return disk_status;
98 }
99
100 formatter.AddField("save_status", "saved");
101 return absl::OkStatus();
102}
103
104template <typename Mutation>
107 Mutation&& mutation) {
108 ScopedRomTransaction transaction(*rom);
109
110 auto write_status = std::forward<Mutation>(mutation)();
111 if (!write_status.ok()) {
112 formatter.AddField("write_error", std::string(write_status.message()));
113 return write_status;
114 }
115 formatter.AddField("write_status", "success");
116
117 RETURN_IF_ERROR(SaveRomWithBackup(rom, formatter));
118 transaction.Commit();
119 return absl::OkStatus();
120}
121
125 // A standalone --manifest is a safety capability, not permission to ignore
126 // ownership. Unlike a project, the CLI has no separate policy setting, so
127 // protected writes are always blocked.
128 project::RomWritePolicy write_policy = project::RomWritePolicy::kBlock;
129};
130
131absl::StatusOr<std::optional<ObjectSaveManifestContext>>
133 const auto manifest_path = parser.GetString("manifest");
134 if (!manifest_path.has_value()) {
135 return std::nullopt;
136 }
137
139 RETURN_IF_ERROR(context.manifest.LoadFromFile(*manifest_path));
140 const core::DungeonStreamLayout* manifest_layout =
143 if (manifest_layout == nullptr) {
144 return absl::FailedPreconditionError(
145 "Manifest does not define dungeon_stream_regions.objects");
146 }
147 if (manifest_layout->strategy != core::DungeonWriteStrategy::kCopyOnWrite) {
148 return absl::FailedPreconditionError(
149 "dungeon_stream_regions.objects must use copy_on_write");
150 }
151
154 core::DungeonStreamType::kObjects, *manifest_layout));
155 return std::optional<ObjectSaveManifestContext>(std::move(context));
156}
157
159 const Rom& source_rom, int room_id, const zelda3::Room& pending_room,
160 const ObjectSaveManifestContext& context) {
161 std::vector<std::pair<uint32_t, uint32_t>> ranges;
162
163 // Match DungeonEditorV2's conservative object-save prediction: validate the
164 // selected in-place stream, the door pointer, every possible COW allocation,
165 // and the selected object-pointer slot before exercising either save path.
166 uint32_t object_pointer_table_snes = 0;
167 ASSIGN_OR_RETURN(object_pointer_table_snes,
169 const uint32_t object_pointer_table_pc = SnesToPc(object_pointer_table_snes);
170 const uint64_t current_pointer_slot =
171 static_cast<uint64_t>(object_pointer_table_pc) +
172 static_cast<uint64_t>(room_id) * 3u;
173 if (current_pointer_slot + 3u > source_rom.size()) {
174 return absl::OutOfRangeError(
175 "Selected object pointer slot is outside the ROM");
176 }
177
178 uint32_t current_stream_snes = 0;
179 ASSIGN_OR_RETURN(current_stream_snes,
180 source_rom.ReadLong(static_cast<int>(current_pointer_slot)));
181 const uint32_t current_stream_pc = SnesToPc(current_stream_snes);
182 const uint64_t current_stream_end = static_cast<uint64_t>(current_stream_pc) +
183 pending_room.EncodeObjects().size() + 2u;
184 if (current_stream_end > std::numeric_limits<uint32_t>::max()) {
185 return absl::OutOfRangeError("Selected object write range overflows");
186 }
187 ranges.emplace_back(current_stream_pc,
188 static_cast<uint32_t>(current_stream_end));
189
190 const uint64_t door_pointer_slot =
191 static_cast<uint64_t>(zelda3::kDoorPointers) +
192 static_cast<uint64_t>(room_id) * 3u;
193 if (door_pointer_slot + 3u > std::numeric_limits<uint32_t>::max()) {
194 return absl::OutOfRangeError("Selected door pointer range overflows");
195 }
196 ranges.emplace_back(static_cast<uint32_t>(door_pointer_slot),
197 static_cast<uint32_t>(door_pointer_slot + 3u));
198
199 for (const auto& range : context.allocator_layout.allocation_ranges) {
200 ranges.emplace_back(range.begin, range.end);
201 }
202 const uint32_t pointer_width = context.allocator_layout.pointer_encoding ==
204 ? 3u
205 : 2u;
206 const uint64_t cow_pointer_slot =
207 static_cast<uint64_t>(context.allocator_layout.pointer_table_pc) +
208 static_cast<uint64_t>(room_id) * pointer_width;
209 if (cow_pointer_slot + pointer_width > std::numeric_limits<uint32_t>::max()) {
210 return absl::OutOfRangeError("Selected COW pointer range overflows");
211 }
212 ranges.emplace_back(static_cast<uint32_t>(cow_pointer_slot),
213 static_cast<uint32_t>(cow_pointer_slot + pointer_width));
214
215 const auto conflicts = context.manifest.AnalyzePcWriteRanges(ranges);
216 if (conflicts.empty() ||
218 return absl::OkStatus();
219 }
220 return absl::PermissionDeniedError("Write conflict with Hack Manifest");
221}
222
224 const Rom& source_rom, int room_id, const zelda3::RoomObject& object,
225 const zelda3::Room& pending_room,
226 const ObjectSaveManifestContext* manifest_context) {
227 if (manifest_context != nullptr) {
229 source_rom, room_id, pending_room, *manifest_context));
230 }
231
232 // Exercise the exact Room::SaveObjects path against an isolated snapshot so
233 // dry-run capacity and allocator outcomes match --write without mutating the
234 // caller's ROM.
235 Rom scratch_rom;
236 Rom::LoadOptions options;
237 options.strip_header = false;
238 options.load_resource_labels = false;
239 RETURN_IF_ERROR(scratch_rom.LoadFromData(source_rom.vector(), options));
240
241 zelda3::Room scratch_room = zelda3::LoadRoomFromRom(&scratch_rom, room_id);
242 RETURN_IF_ERROR(scratch_room.AddObject(object));
243 return scratch_room.SaveObjects(manifest_context != nullptr
244 ? &manifest_context->allocator_layout
245 : nullptr);
246}
247
249 const Rom& source_rom, int room_id, int object_vector_index,
250 const zelda3::Room& pending_room,
251 const ObjectSaveManifestContext* manifest_context) {
252 if (manifest_context != nullptr) {
254 source_rom, room_id, pending_room, *manifest_context));
255 }
256
257 Rom scratch_rom;
258 Rom::LoadOptions options;
259 options.strip_header = false;
260 options.load_resource_labels = false;
261 RETURN_IF_ERROR(scratch_rom.LoadFromData(source_rom.vector(), options));
262
263 zelda3::Room scratch_room = zelda3::LoadRoomFromRom(&scratch_rom, room_id);
264 RETURN_IF_ERROR(scratch_room.RemoveObject(object_vector_index));
265 return scratch_room.SaveObjects(manifest_context != nullptr
266 ? &manifest_context->allocator_layout
267 : nullptr);
268}
269
270absl::StatusOr<int> GetRequiredHex(const resources::ArgumentParser& parser,
271 const char* name) {
272 auto value_or = GetRequiredString(parser, name);
273 if (!value_or.ok()) {
274 return value_or.status();
275 }
276
277 int value = 0;
278 if (!ParseHexString(*value_or, &value)) {
279 return absl::InvalidArgumentError(absl::StrFormat(
280 "Invalid --%s value '%s' (expected integer/hex)", name, *value_or));
281 }
282 return value;
283}
284
285absl::Status ValidateDoorType(int type, const char* argument_name) {
286 if (type < 0 || type > 0x66 || (type & 1) != 0) {
287 return absl::InvalidArgumentError(absl::StrFormat(
288 "--%s must be an even door type in range 0x00-0x66", argument_name));
289 }
290 return absl::OkStatus();
291}
292
293bool ContainsRoomId(const std::vector<uint32_t>& room_ids, int room_id) {
294 return std::find(room_ids.begin(), room_ids.end(),
295 static_cast<uint32_t>(room_id)) != room_ids.end();
296}
297
298bool IsLoRomDataPointer(uint32_t snes_address) {
299 if ((snes_address & 0xFFFFu) < 0x8000u) {
300 return false;
301 }
302 const uint8_t normalized_bank =
303 static_cast<uint8_t>((snes_address >> 16) & 0x7Fu);
304 if (normalized_bank >= 0x7Eu) {
305 return false;
306 }
307 const uint32_t pc_address = SnesToPc(snes_address);
308 return (PcToSnes(pc_address) & 0x7FFFFFu) == (snes_address & 0x7FFFFFu);
309}
310
311constexpr int kDungeonPaletteColorCount = 90;
314constexpr uint32_t kSnesDestinationCodePc = 0x7FD9;
315constexpr uint32_t kDungeonPaletteDataEnd =
318
319bool IsValidRomRange(const Rom& rom, uint64_t start, uint64_t length) {
320 return start <= rom.size() && length <= rom.size() - start;
321}
322
323absl::Status ValidateSupportedDungeonPaletteRom(const Rom& rom) {
325 return absl::FailedPreconditionError(
326 "ROM is too small to prove US/OOS dungeon palette table layout");
327 }
328 uint8_t destination_code = 0;
329 ASSIGN_OR_RETURN(destination_code,
330 rom.ReadByte(static_cast<int>(kSnesDestinationCodePc)));
331 if (destination_code != 0x01) {
332 return absl::FailedPreconditionError(absl::StrFormat(
333 "Dungeon palette commands currently support only the US/OOS table "
334 "layout (SNES destination code 0x01); got unsupported code 0x%02X",
335 destination_code));
336 }
337 return absl::OkStatus();
338}
339
341 int room_id = -1;
342 uint8_t palette_set_id = 0;
343 uint8_t palette_pointer_offset = 0;
344 uint16_t palette_pointer_word = 0;
345 int palette_index = -1;
346 uint32_t room_header_pc = 0;
347 uint32_t palette_set_pc = 0;
348 uint32_t palette_pointer_pc = 0;
349 uint32_t palette_pc = 0;
350};
351
352absl::StatusOr<uint32_t> ResolveRoomHeaderPcStrict(const Rom& rom,
353 int room_id) {
357 return absl::FailedPreconditionError(
358 "Dungeon room-header pointer metadata is outside the ROM");
359 }
360
361 uint32_t table_snes = 0;
363 if (!IsLoRomDataPointer(table_snes)) {
364 return absl::FailedPreconditionError(absl::StrFormat(
365 "Dungeon room-header pointer table is not a valid LoROM pointer: "
366 "0x%06X",
367 table_snes));
368 }
369 const uint32_t table_pc = SnesToPc(table_snes);
370 const uint64_t slot_pc =
371 static_cast<uint64_t>(table_pc) + static_cast<uint64_t>(room_id) * 2u;
372 if (!IsValidRomRange(rom, slot_pc, 2)) {
373 return absl::FailedPreconditionError(absl::StrFormat(
374 "Dungeon room 0x%03X header pointer slot is outside the ROM", room_id));
375 }
376
377 uint16_t header_offset = 0;
378 uint8_t header_bank = 0;
379 ASSIGN_OR_RETURN(header_offset, rom.ReadWord(static_cast<int>(slot_pc)));
381 const uint32_t header_snes =
382 (static_cast<uint32_t>(header_bank) << 16) | header_offset;
383 if (!IsLoRomDataPointer(header_snes)) {
384 return absl::FailedPreconditionError(absl::StrFormat(
385 "Dungeon room 0x%03X header is not a valid LoROM pointer: 0x%06X",
386 room_id, header_snes));
387 }
388
389 const uint32_t header_pc = SnesToPc(header_snes);
390 if (!IsValidRomRange(rom, header_pc, 14)) {
391 return absl::FailedPreconditionError(absl::StrFormat(
392 "Dungeon room 0x%03X header is outside the ROM", room_id));
393 }
394 return header_pc;
395}
396
397absl::StatusOr<DungeonPaletteRoomMapping> ResolveDungeonPaletteRoomMapping(
398 const Rom& rom, int room_id) {
400 mapping.room_id = room_id;
402 ResolveRoomHeaderPcStrict(rom, room_id));
404 rom.ReadByte(static_cast<int>(mapping.room_header_pc + 1u)));
406 return absl::FailedPreconditionError(absl::StrFormat(
407 "Dungeon room 0x%03X has out-of-range 8-bit palette-set ID 0x%02X "
408 "(expected 0x00-0x%02X)",
409 room_id, mapping.palette_set_id, zelda3::kNumPalettesets - 1));
410 }
411
413 static_cast<uint32_t>(mapping.palette_set_id) * 4u;
414 if (!IsValidRomRange(rom, mapping.palette_set_pc, 4)) {
415 return absl::FailedPreconditionError(absl::StrFormat(
416 "Palette-set entry 0x%02X is outside the ROM", mapping.palette_set_id));
417 }
419 rom.ReadByte(static_cast<int>(mapping.palette_set_pc)));
420 if ((mapping.palette_pointer_offset & 1u) != 0u ||
422 return absl::FailedPreconditionError(absl::StrFormat(
423 "Palette-set 0x%02X has malformed dungeon palette pointer offset "
424 "0x%02X (expected an even byte offset 0x00-0x%02X)",
425 mapping.palette_set_id, mapping.palette_pointer_offset,
426 kDungeonPaletteCount * 2 - 2));
427 }
428
429 mapping.palette_pointer_pc =
431 if (!IsValidRomRange(rom, mapping.palette_pointer_pc, 2)) {
432 return absl::FailedPreconditionError(
433 "Resolved dungeon palette pointer is outside the ROM");
434 }
436 rom.ReadWord(static_cast<int>(mapping.palette_pointer_pc)));
438 return absl::FailedPreconditionError(absl::StrFormat(
439 "Palette-set 0x%02X resolves to malformed palette word 0x%04X "
440 "(not divisible by %d)",
441 mapping.palette_set_id, mapping.palette_pointer_word,
443 }
444
445 mapping.palette_index =
447 if (mapping.palette_index < 0 ||
449 return absl::FailedPreconditionError(absl::StrFormat(
450 "Palette-set 0x%02X resolves to out-of-range dungeon palette %d",
451 mapping.palette_set_id, mapping.palette_index));
452 }
453
455 if (mapping.palette_pc < kDungeonPaletteDataStart ||
456 static_cast<uint64_t>(mapping.palette_pc) + zelda3::kDungeonPaletteBytes >
459 return absl::FailedPreconditionError(absl::StrFormat(
460 "Palette-set 0x%02X resolves outside the fixed dungeon palette "
461 "region [0x%06X,0x%06X)",
464 }
465 return mapping;
466}
467
472
473absl::StatusOr<DungeonPaletteSelection> ResolveDungeonPaletteSelection(
474 const Rom& rom, int room_id) {
476 DungeonPaletteSelection selection;
477 ASSIGN_OR_RETURN(selection.selected,
479 selection.affected_rooms.reserve(zelda3::kNumberOfRooms);
480 for (int candidate = 0; candidate < zelda3::kNumberOfRooms; ++candidate) {
481 auto mapping_or = ResolveDungeonPaletteRoomMapping(rom, candidate);
482 if (!mapping_or.ok()) {
483 return absl::Status(
484 mapping_or.status().code(),
485 absl::StrFormat(
486 "Cannot prove complete shared palette fanout because room "
487 "0x%03X mapping is invalid: %s",
488 candidate, mapping_or.status().message()));
489 }
490 if (mapping_or->palette_index == selection.selected.palette_index) {
491 selection.affected_rooms.push_back(static_cast<uint32_t>(candidate));
492 }
493 }
494 return selection;
495}
496
497absl::Status ValidateDungeonPaletteColorIndex(int color_index) {
498 if (color_index < 0 || color_index >= kDungeonPaletteColorCount) {
499 return absl::InvalidArgumentError(absl::StrFormat(
500 "--index must be in range 0-%d", kDungeonPaletteColorCount - 1));
501 }
502 return absl::OkStatus();
503}
504
505absl::Status ValidateSnes15BitColor(int color, const char* argument_name) {
506 if (color < 0 || color > 0x7FFF) {
507 return absl::InvalidArgumentError(absl::StrFormat(
508 "--%s must be a 15-bit SNES color in range 0x0000-0x7FFF",
509 argument_name));
510 }
511 return absl::OkStatus();
512}
513
515 int color_index) {
516 return mapping.palette_pc + static_cast<uint32_t>(color_index) * 2u;
517}
518
519absl::StatusOr<uint16_t> ReadDungeonPaletteColor(
520 const Rom& rom, const DungeonPaletteRoomMapping& mapping, int color_index) {
522 const uint32_t color_pc = DungeonPaletteColorPc(mapping, color_index);
523 if (!IsValidRomRange(rom, color_pc, 2) ||
524 color_pc < kDungeonPaletteDataStart ||
525 static_cast<uint64_t>(color_pc) + 2u > kDungeonPaletteDataEnd) {
526 return absl::FailedPreconditionError(
527 "Resolved dungeon palette color is outside the fixed palette region");
528 }
529 uint16_t color = 0;
530 ASSIGN_OR_RETURN(color, rom.ReadWord(static_cast<int>(color_pc)));
531 if (color > 0x7FFF) {
532 return absl::FailedPreconditionError(absl::StrFormat(
533 "Resolved dungeon palette color at PC 0x%06X is not a 15-bit SNES "
534 "color: 0x%04X",
535 color_pc, color));
536 }
537 return color;
538}
539
541 const DungeonPaletteSelection& selection) {
542 const auto& mapping = selection.selected;
543 formatter.AddHexField("room_id", mapping.room_id, 3);
544 formatter.AddHexField("palette_set_id", mapping.palette_set_id, 2);
545 formatter.AddField("resolved_palette_index", mapping.palette_index);
546 formatter.AddHexField("room_header_pc", mapping.room_header_pc, 6);
547 formatter.AddHexField("palette_set_pc", mapping.palette_set_pc, 6);
548 formatter.AddHexField("palette_pointer_offset",
549 mapping.palette_pointer_offset, 2);
550 formatter.AddHexField("palette_pointer_pc", mapping.palette_pointer_pc, 6);
551 formatter.AddHexField("palette_pointer_word", mapping.palette_pointer_word,
552 4);
553 formatter.AddHexField("palette_pc", mapping.palette_pc, 6);
554 formatter.AddHexField("palette_snes", PcToSnes(mapping.palette_pc), 6);
555 formatter.AddField("scope", "shared_global_dungeon_palette");
556 formatter.AddField("affected_room_count",
557 static_cast<int>(selection.affected_rooms.size()));
558 formatter.BeginArray("affected_rooms");
559 for (uint32_t affected_room : selection.affected_rooms) {
560 formatter.AddArrayItem(absl::StrFormat("0x%03X", affected_room));
561 }
562 formatter.EndArray();
563}
564
566 const core::HackManifest& manifest, uint32_t color_pc) {
567 const auto conflicts =
568 manifest.AnalyzePcWriteRanges({{color_pc, color_pc + 2u}});
569 if (conflicts.empty()) {
570 return absl::OkStatus();
571 }
572 const core::WriteConflict& conflict = conflicts.front();
573 return absl::PermissionDeniedError(absl::StrFormat(
574 "Dungeon palette color range [0x%06X,0x%06X) conflicts with Hack "
575 "Manifest ownership at SNES 0x%06X (%s%s%s)",
576 color_pc, color_pc + 2u, conflict.address,
578 conflict.module.empty() ? "" : ", module=",
579 conflict.module.empty() ? "" : conflict.module));
580}
581
582absl::StatusOr<std::vector<uint32_t>> ValidateDungeonPaletteWholeRomDiff(
583 const std::vector<uint8_t>& before, const Rom& after, uint32_t color_pc) {
584 if (after.size() != before.size()) {
585 return absl::DataLossError(
586 "Dungeon palette write unexpectedly resized the ROM");
587 }
588
589 std::vector<uint32_t> changed;
590 for (uint32_t pc = 0; pc < before.size(); ++pc) {
591 if (before[pc] == after.data()[pc]) {
592 continue;
593 }
594 if (pc < color_pc || pc >= color_pc + 2u) {
595 return absl::DataLossError(absl::StrFormat(
596 "Dungeon palette write changed unexpected ROM byte 0x%06X", pc));
597 }
598 changed.push_back(pc);
599 }
600 if (changed.empty() || changed.size() > 2u) {
601 return absl::DataLossError(absl::StrFormat(
602 "Dungeon palette write changed %zu bytes; expected one or two bytes "
603 "inside the fenced color word",
604 changed.size()));
605 }
606 return changed;
607}
608
609absl::Status ValidateDungeonPaletteWriteBaseline(const Rom& rom) {
610 if (rom.dirty()) {
611 return absl::FailedPreconditionError(
612 "Dungeon palette write requires a clean file-backed ROM; save or "
613 "discard unrelated in-memory edits first");
614 }
615 if (rom.filename().empty()) {
616 return absl::FailedPreconditionError(
617 "Dungeon palette write requires a ROM filename");
618 }
619
620 Rom disk_rom;
621 Rom::LoadOptions options;
622 options.strip_header = false;
623 options.load_resource_labels = false;
624 RETURN_IF_ERROR(disk_rom.LoadFromFile(rom.filename(), options));
625 if (disk_rom.vector() != rom.vector()) {
626 return absl::FailedPreconditionError(
627 "Dungeon palette write requires in-memory ROM bytes to exactly match "
628 "the current file; refusing to persist unrelated or stale bytes");
629 }
630 return absl::OkStatus();
631}
632
634 const Rom& saved_rom, int room_id, int expected_palette_set,
635 int expected_palette_index, int color_index, uint16_t expected_color) {
636 if (saved_rom.filename().empty()) {
637 return absl::FailedPreconditionError(
638 "Write mode requires a ROM filename for external readback");
639 }
640
641 Rom reopened;
642 Rom::LoadOptions options;
643 options.strip_header = false;
644 options.load_resource_labels = false;
645 RETURN_IF_ERROR(reopened.LoadFromFile(saved_rom.filename(), options));
646 if (reopened.vector() != saved_rom.vector()) {
647 return absl::DataLossError(
648 "External reopen bytes do not match the saved in-memory ROM");
649 }
650
652 DungeonPaletteRoomMapping reopened_mapping;
653 ASSIGN_OR_RETURN(reopened_mapping,
654 ResolveDungeonPaletteRoomMapping(reopened, room_id));
655 if (reopened_mapping.palette_set_id != expected_palette_set ||
656 reopened_mapping.palette_index != expected_palette_index) {
657 return absl::DataLossError(
658 "External reopen changed the selected dungeon palette mapping");
659 }
660 uint16_t reopened_color = 0;
662 reopened_color,
663 ReadDungeonPaletteColor(reopened, reopened_mapping, color_index));
664 if (reopened_color != expected_color) {
665 return absl::DataLossError(absl::StrFormat(
666 "External reopen color mismatch: expected 0x%04X, got 0x%04X",
667 expected_color, reopened_color));
668 }
669 return absl::OkStatus();
670}
671
673 int door_index = -1;
675 uint32_t object_stream_pc = 0;
676 uint32_t object_stream_end_pc = 0;
677 uint32_t door_pointer_pc = 0;
678 uint32_t door_entry_pc = 0;
679 uint32_t type_pc = 0;
680};
681
682absl::StatusOr<DoorTypeTarget> ResolveDoorTypeTarget(
683 Rom* rom, int room_id, int x, int y,
684 const ObjectSaveManifestContext& manifest_context) {
685 if (manifest_context.allocator_layout.pointer_encoding !=
687 return absl::FailedPreconditionError(
688 "Dungeon object manifest pointer encoding must be long24");
689 }
690 if (manifest_context.allocator_layout.pointer_count !=
692 return absl::FailedPreconditionError(absl::StrFormat(
693 "Dungeon object manifest pointer_count must equal %d rooms",
695 }
696
697 uint32_t runtime_pointer_table_snes = 0;
698 ASSIGN_OR_RETURN(runtime_pointer_table_snes,
700 if (!IsLoRomDataPointer(runtime_pointer_table_snes)) {
701 return absl::FailedPreconditionError(
702 "Runtime object pointer table is not a valid LoROM data pointer");
703 }
704 const uint32_t runtime_pointer_table_pc =
705 SnesToPc(runtime_pointer_table_snes);
706 if (runtime_pointer_table_pc !=
707 manifest_context.allocator_layout.pointer_table_pc) {
708 return absl::FailedPreconditionError(absl::StrFormat(
709 "Manifest object pointer table PC 0x%06X does not match runtime "
710 "table PC 0x%06X",
711 manifest_context.allocator_layout.pointer_table_pc,
712 runtime_pointer_table_pc));
713 }
714
717 *rom, manifest_context.allocator_layout));
718 if (static_cast<size_t>(room_id) >= inventory.streams.size()) {
719 return absl::FailedPreconditionError(
720 "Selected room is absent from the object stream inventory");
721 }
722 for (const auto& issue : inventory.issues) {
723 if (issue.room_id == static_cast<uint32_t>(room_id)) {
724 return absl::FailedPreconditionError(absl::StrFormat(
725 "Selected object stream is invalid: %s", issue.message));
726 }
727 }
728
729 const auto& stream = inventory.streams[room_id];
730 if (!stream.valid) {
731 return absl::FailedPreconditionError("Selected object stream is invalid");
732 }
733 for (const auto& alias : inventory.aliases) {
734 if (ContainsRoomId(alias.room_ids, room_id)) {
735 return absl::FailedPreconditionError(absl::StrFormat(
736 "Selected object stream at PC 0x%06X is shared by %d rooms",
737 alias.data_pc, static_cast<int>(alias.room_ids.size())));
738 }
739 }
740 for (const auto& overlap : inventory.overlaps) {
741 if (ContainsRoomId(overlap.first_room_ids, room_id) ||
742 ContainsRoomId(overlap.second_room_ids, room_id)) {
743 return absl::FailedPreconditionError(absl::StrFormat(
744 "Selected object stream overlaps PC range [0x%06X, 0x%06X)",
745 overlap.intersection.begin, overlap.intersection.end));
746 }
747 }
748 for (const auto& other_stream : inventory.streams) {
749 if (other_stream.room_id == static_cast<uint32_t>(room_id)) {
750 continue;
751 }
752 if (other_stream.data_pc >= stream.data_pc &&
753 other_stream.data_pc < stream.logical_end_pc) {
754 return absl::FailedPreconditionError(absl::StrFormat(
755 "Room 0x%03X object pointer PC 0x%06X starts inside selected "
756 "object stream [0x%06X, 0x%06X)",
757 other_stream.room_id, other_stream.data_pc, stream.data_pc,
758 stream.logical_end_pc));
759 }
760 }
761 for (int other_room_id = 0; other_room_id < zelda3::kNumberOfRooms;
762 ++other_room_id) {
763 if (other_room_id == room_id) {
764 continue;
765 }
766 const uint32_t other_door_pointer_slot =
767 zelda3::kDoorPointers + static_cast<uint32_t>(other_room_id) * 3u;
768 uint32_t other_door_pointer_snes = 0;
769 ASSIGN_OR_RETURN(other_door_pointer_snes,
770 rom->ReadLong(static_cast<int>(other_door_pointer_slot)));
771 if (!IsLoRomDataPointer(other_door_pointer_snes)) {
772 continue;
773 }
774 const uint32_t other_door_pointer_pc = SnesToPc(other_door_pointer_snes);
775 if (other_door_pointer_pc >= stream.data_pc &&
776 other_door_pointer_pc < stream.logical_end_pc) {
777 return absl::FailedPreconditionError(absl::StrFormat(
778 "Room 0x%03X door pointer PC 0x%06X points inside selected "
779 "object stream [0x%06X, 0x%06X)",
780 other_room_id, other_door_pointer_pc, stream.data_pc,
781 stream.logical_end_pc));
782 }
783 }
784
785 const uint64_t runtime_pointer_slot =
786 static_cast<uint64_t>(runtime_pointer_table_pc) +
787 static_cast<uint64_t>(room_id) * 3u;
788 if (runtime_pointer_slot + 3u > rom->size() ||
789 runtime_pointer_slot != stream.pointer_slot_pc) {
790 return absl::FailedPreconditionError(
791 "Selected runtime object pointer slot does not match the manifest");
792 }
793 uint32_t runtime_stream_snes = 0;
794 ASSIGN_OR_RETURN(runtime_stream_snes,
795 rom->ReadLong(static_cast<int>(runtime_pointer_slot)));
796 if (!IsLoRomDataPointer(runtime_stream_snes)) {
797 return absl::FailedPreconditionError(
798 "Selected runtime object stream is not a valid LoROM data pointer");
799 }
800 const uint32_t runtime_stream_pc = SnesToPc(runtime_stream_snes);
801 if (runtime_stream_pc != stream.data_pc) {
802 return absl::FailedPreconditionError(absl::StrFormat(
803 "Selected runtime object stream PC 0x%06X does not match inventory "
804 "PC 0x%06X",
805 runtime_stream_pc, stream.data_pc));
806 }
807
808 zelda3::Room room = zelda3::LoadRoomFromRom(rom, room_id);
809 const auto& doors = room.GetDoors();
810 int selected_index = -1;
811 for (int index = 0; index < static_cast<int>(doors.size()); ++index) {
812 const auto [door_x, door_y] = doors[index].GetTileCoords();
813 if (door_x != x || door_y != y) {
814 continue;
815 }
816 if (selected_index >= 0) {
817 return absl::FailedPreconditionError(absl::StrFormat(
818 "Multiple doors match tile coordinates (%d, %d)", x, y));
819 }
820 selected_index = index;
821 }
822 if (selected_index < 0) {
823 return absl::NotFoundError(absl::StrFormat(
824 "No door matches tile coordinates (%d, %d) in room 0x%03X", x, y,
825 room_id));
826 }
827
828 const uint64_t door_pointer_slot =
829 static_cast<uint64_t>(zelda3::kDoorPointers) +
830 static_cast<uint64_t>(room_id) * 3u;
831 if (door_pointer_slot + 3u > rom->size()) {
832 return absl::OutOfRangeError("Selected door pointer slot is outside ROM");
833 }
834 uint32_t door_pointer_snes = 0;
835 ASSIGN_OR_RETURN(door_pointer_snes,
836 rom->ReadLong(static_cast<int>(door_pointer_slot)));
837 if (!IsLoRomDataPointer(door_pointer_snes)) {
838 return absl::FailedPreconditionError(
839 "Selected door pointer is not a valid LoROM data pointer");
840 }
841 const uint32_t door_pointer_pc = SnesToPc(door_pointer_snes);
842 const uint64_t door_entry_pc = static_cast<uint64_t>(door_pointer_pc) +
843 static_cast<uint64_t>(selected_index) * 2u;
844 const uint64_t expected_stream_end =
845 static_cast<uint64_t>(door_pointer_pc) +
846 static_cast<uint64_t>(doors.size()) * 2u + 2u;
847 if (door_pointer_pc < stream.data_pc ||
848 door_entry_pc + 2u > stream.logical_end_pc ||
849 expected_stream_end != stream.logical_end_pc) {
850 return absl::FailedPreconditionError(
851 "Door pointer/count does not match the inventoried object stream end");
852 }
853
854 uint8_t raw_byte1 = 0;
855 uint8_t raw_byte2 = 0;
856 for (int index = 0; index < static_cast<int>(doors.size()); ++index) {
857 const uint32_t entry_pc =
858 door_pointer_pc + static_cast<uint32_t>(index) * 2u;
859 uint8_t entry_byte1 = 0;
860 uint8_t entry_byte2 = 0;
861 ASSIGN_OR_RETURN(entry_byte1, rom->ReadByte(static_cast<int>(entry_pc)));
862 ASSIGN_OR_RETURN(entry_byte2,
863 rom->ReadByte(static_cast<int>(entry_pc + 1u)));
864 const auto [model_byte1, model_byte2] = doors[index].EncodeBytes();
865 if (model_byte1 != entry_byte1 || model_byte2 != entry_byte2 ||
866 doors[index].byte1 != entry_byte1 ||
867 doors[index].byte2 != entry_byte2) {
868 return absl::DataLossError(
869 "Door model does not match the raw door-pointer entries");
870 }
871 if (index == selected_index) {
872 raw_byte1 = entry_byte1;
873 raw_byte2 = entry_byte2;
874 }
875 }
876 uint16_t door_terminator = 0;
877 ASSIGN_OR_RETURN(door_terminator,
878 rom->ReadWord(static_cast<int>(expected_stream_end - 2u)));
879 if (door_terminator != 0xFFFFu) {
880 return absl::DataLossError(
881 "Door list does not end with the expected 0xFFFF terminator");
882 }
883
884 const zelda3::Room::Door raw_door =
885 zelda3::Room::Door::FromRomBytes(raw_byte1, raw_byte2);
886 const zelda3::Room::Door& model_door = doors[selected_index];
887 const auto [raw_x, raw_y] = raw_door.GetTileCoords();
888 if (model_door.position != raw_door.position ||
889 model_door.direction != raw_door.direction ||
890 model_door.type != raw_door.type || raw_x != x || raw_y != y) {
891 return absl::DataLossError(
892 "Door model does not match the raw door-pointer entry");
893 }
894
895 const uint32_t type_pc = static_cast<uint32_t>(door_entry_pc + 1u);
896 const auto conflicts =
897 manifest_context.manifest.AnalyzePcWriteRanges({{type_pc, type_pc + 1u}});
898 if (!conflicts.empty()) {
899 return absl::PermissionDeniedError(absl::StrFormat(
900 "Door type byte at PC 0x%06X conflicts with Hack Manifest (%s)",
901 type_pc, core::AddressOwnershipToString(conflicts.front().ownership)));
902 }
903
904 DoorTypeTarget target;
905 target.door_index = selected_index;
906 target.door = model_door;
907 target.object_stream_pc = stream.data_pc;
908 target.object_stream_end_pc = stream.logical_end_pc;
909 target.door_pointer_pc = door_pointer_pc;
910 target.door_entry_pc = static_cast<uint32_t>(door_entry_pc);
911 target.type_pc = type_pc;
912 return target;
913}
914
919
920absl::StatusOr<PotItemManifestContext> LoadPotItemManifestContext(
921 const resources::ArgumentParser& parser) {
922 std::string manifest_path;
923 ASSIGN_OR_RETURN(manifest_path, GetRequiredString(parser, "manifest"));
924
926 RETURN_IF_ERROR(context.manifest.LoadFromFile(manifest_path));
927 const core::DungeonStreamLayout* manifest_layout =
930 if (manifest_layout == nullptr) {
931 return absl::FailedPreconditionError(
932 "Manifest does not define dungeon_stream_regions.pot_items");
933 }
934 ASSIGN_OR_RETURN(context.layout,
936 core::DungeonStreamType::kPotItems, *manifest_layout));
937 return context;
938}
939
941 int index = -1;
943 uint32_t entry_pc = 0;
944 uint32_t item_pc = 0;
945};
946
948 uint32_t pointer_slot_pc = 0;
949 uint32_t stream_pc = 0;
950 uint32_t stream_end_pc = 0;
951 std::vector<PotItemEntryView> entries;
952};
953
954absl::StatusOr<PotItemStreamView> ResolveRuntimePotItemStream(Rom* rom,
955 int room_id) {
956 if (rom == nullptr || !rom->is_loaded()) {
957 return absl::InvalidArgumentError("ROM not loaded");
958 }
959
960 const uint64_t pointer_table_end =
961 static_cast<uint64_t>(zelda3::kRoomItemsPointers) +
962 static_cast<uint64_t>(zelda3::kNumberOfRooms) * 2u;
963 if (pointer_table_end > rom->size()) {
964 return absl::OutOfRangeError(
965 "Complete pot-item pointer table is outside ROM");
966 }
967 const uint32_t pointer_slot_pc =
968 zelda3::kRoomItemsPointers + static_cast<uint32_t>(room_id) * 2u;
969 uint16_t pointer_word = 0;
970 ASSIGN_OR_RETURN(pointer_word,
971 rom->ReadWord(static_cast<int>(pointer_slot_pc)));
972 const uint32_t pointer_snes = 0x010000u | pointer_word;
973 if (!IsLoRomDataPointer(pointer_snes)) {
974 return absl::FailedPreconditionError(
975 "Selected pot-item pointer is not a valid bank-01 LoROM pointer");
976 }
977 const uint32_t stream_pc = SnesToPc(pointer_snes);
978 const uint64_t bank_end =
979 (static_cast<uint64_t>(stream_pc) / 0x8000u + 1u) * 0x8000u;
980 const uint32_t parse_end =
981 static_cast<uint32_t>(std::min<uint64_t>(rom->size(), bank_end));
982 if (stream_pc >= parse_end) {
983 return absl::OutOfRangeError("Selected pot-item stream is outside ROM");
984 }
985
987 view.pointer_slot_pc = pointer_slot_pc;
988 view.stream_pc = stream_pc;
989
990 uint32_t cursor = stream_pc;
991 while (true) {
992 if (static_cast<uint64_t>(cursor) + 2u > parse_end) {
993 return absl::DataLossError("Pot-item stream has no 0xFFFF terminator");
994 }
995 uint16_t position = 0;
996 ASSIGN_OR_RETURN(position, rom->ReadWord(static_cast<int>(cursor)));
997 if (position == 0xFFFFu) {
998 view.stream_end_pc = cursor + 2u;
999 break;
1000 }
1001 if (static_cast<uint64_t>(cursor) + 3u > parse_end) {
1002 return absl::DataLossError("Pot-item stream has a truncated record");
1003 }
1004 uint8_t item = 0;
1005 ASSIGN_OR_RETURN(item, rom->ReadByte(static_cast<int>(cursor + 2u)));
1006 view.entries.push_back({.index = static_cast<int>(view.entries.size()),
1007 .item = {.position = position, .item = item},
1008 .entry_pc = cursor,
1009 .item_pc = cursor + 2u});
1010 cursor += 3u;
1011 }
1012
1013 zelda3::Room room(room_id, rom);
1014 room.LoadPotItems();
1015 if (!room.ArePotItemsLoaded() ||
1016 room.GetPotItems().size() != view.entries.size()) {
1017 return absl::DataLossError(
1018 "Pot-item room model count does not match the raw stream");
1019 }
1020 for (size_t index = 0; index < view.entries.size(); ++index) {
1021 const zelda3::PotItem& model = room.GetPotItems()[index];
1022 if (model.position != view.entries[index].item.position ||
1023 model.item != view.entries[index].item.item) {
1024 return absl::DataLossError(
1025 "Pot-item room model does not match the raw stream");
1026 }
1027 }
1028 return view;
1029}
1030
1033 uint32_t pointer_slot_pc = 0;
1034 uint32_t stream_pc = 0;
1035 uint32_t stream_end_pc = 0;
1036};
1037
1038absl::StatusOr<PotItemTarget> ResolvePotItemTarget(
1039 Rom* rom, int room_id, int index,
1040 const PotItemManifestContext& manifest_context) {
1041 if (manifest_context.layout.pointer_count != zelda3::kNumberOfRooms) {
1042 return absl::FailedPreconditionError(absl::StrFormat(
1043 "Dungeon pot-item manifest pointer_count must equal %d rooms",
1045 }
1046
1049 *rom, manifest_context.layout));
1050 if (static_cast<size_t>(room_id) >= inventory.streams.size()) {
1051 return absl::FailedPreconditionError(
1052 "Selected room is absent from the pot-item stream inventory");
1053 }
1054 if (!inventory.issues.empty()) {
1055 const auto& issue = inventory.issues.front();
1056 return absl::FailedPreconditionError(absl::StrFormat(
1057 "Pot-item stream inventory is invalid at room 0x%03X: %s",
1058 issue.room_id, issue.message));
1059 }
1060
1061 const auto& stream = inventory.streams[room_id];
1062 if (!stream.valid) {
1063 return absl::FailedPreconditionError("Selected pot-item stream is invalid");
1064 }
1065 for (const auto& alias : inventory.aliases) {
1066 if (ContainsRoomId(alias.room_ids, room_id)) {
1067 return absl::FailedPreconditionError(absl::StrFormat(
1068 "Selected pot-item stream at PC 0x%06X is shared by %d rooms",
1069 alias.data_pc, static_cast<int>(alias.room_ids.size())));
1070 }
1071 }
1072 for (const auto& overlap : inventory.overlaps) {
1073 if (ContainsRoomId(overlap.first_room_ids, room_id) ||
1074 ContainsRoomId(overlap.second_room_ids, room_id)) {
1075 return absl::FailedPreconditionError(absl::StrFormat(
1076 "Selected pot-item stream overlaps PC range [0x%06X, 0x%06X)",
1077 overlap.intersection.begin, overlap.intersection.end));
1078 }
1079 }
1080 for (const auto& other_stream : inventory.streams) {
1081 if (other_stream.room_id == static_cast<uint32_t>(room_id)) {
1082 continue;
1083 }
1084 if (other_stream.data_pc >= stream.data_pc &&
1085 other_stream.data_pc < stream.logical_end_pc) {
1086 return absl::FailedPreconditionError(absl::StrFormat(
1087 "Room 0x%03X pot-item pointer PC 0x%06X starts inside selected "
1088 "pot-item stream [0x%06X, 0x%06X)",
1089 other_stream.room_id, other_stream.data_pc, stream.data_pc,
1090 stream.logical_end_pc));
1091 }
1092 }
1093
1094 PotItemStreamView runtime;
1095 ASSIGN_OR_RETURN(runtime, ResolveRuntimePotItemStream(rom, room_id));
1096 if (runtime.pointer_slot_pc != stream.pointer_slot_pc ||
1097 runtime.stream_pc != stream.data_pc ||
1098 runtime.stream_end_pc != stream.logical_end_pc ||
1099 runtime.entries.size() * 3u + 2u != stream.encoded_stream.size()) {
1100 return absl::DataLossError(
1101 "Runtime pot-item stream does not match the manifest inventory");
1102 }
1103 if (index < 0 || index >= static_cast<int>(runtime.entries.size())) {
1104 return absl::OutOfRangeError(
1105 absl::StrFormat("Pot-item index %d out of range (room has %d entries)",
1106 index, static_cast<int>(runtime.entries.size())));
1107 }
1108
1109 const PotItemEntryView& entry = runtime.entries[index];
1110 const auto conflicts = manifest_context.manifest.AnalyzePcWriteRanges(
1111 {{entry.item_pc, entry.item_pc + 1u}});
1112 if (!conflicts.empty()) {
1113 return absl::PermissionDeniedError(absl::StrFormat(
1114 "Pot-item byte at PC 0x%06X conflicts with Hack Manifest (%s)",
1115 entry.item_pc,
1116 core::AddressOwnershipToString(conflicts.front().ownership)));
1117 }
1118
1119 return PotItemTarget{
1120 .entry = entry,
1121 .pointer_slot_pc = runtime.pointer_slot_pc,
1122 .stream_pc = runtime.stream_pc,
1123 .stream_end_pc = runtime.stream_end_pc,
1124 };
1125}
1126
1127} // namespace
1128
1129// ---------------------------------------------------------------------------
1130// dungeon-place-sprite
1131// ---------------------------------------------------------------------------
1132
1134 Rom* rom, const resources::ArgumentParser& parser,
1135 resources::OutputFormatter& formatter) {
1136 auto room_id_str_or = GetRequiredString(parser, "room");
1137 if (!room_id_str_or.ok()) {
1138 return room_id_str_or.status();
1139 }
1140 auto sprite_id_str_or = GetRequiredString(parser, "id");
1141 if (!sprite_id_str_or.ok()) {
1142 return sprite_id_str_or.status();
1143 }
1144 const std::string room_id_str = room_id_str_or.value();
1145 const std::string sprite_id_str = sprite_id_str_or.value();
1146
1147 int room_id, sprite_id;
1148 if (!ParseHexString(room_id_str, &room_id)) {
1149 return absl::InvalidArgumentError("Invalid room ID. Must be hex.");
1150 }
1151 if (!ParseHexString(sprite_id_str, &sprite_id)) {
1152 return absl::InvalidArgumentError("Invalid sprite ID. Must be hex.");
1153 }
1154 auto room_status = ValidateRoomId(room_id);
1155 if (!room_status.ok()) {
1156 return room_status;
1157 }
1158
1159 auto x_or = GetRequiredInt(parser, "x");
1160 if (!x_or.ok()) {
1161 return x_or.status();
1162 }
1163 auto y_or = GetRequiredInt(parser, "y");
1164 if (!y_or.ok()) {
1165 return y_or.status();
1166 }
1167 auto subtype_or = GetOptionalInt(parser, "subtype", 0);
1168 if (!subtype_or.ok()) {
1169 return subtype_or.status();
1170 }
1171 auto layer_or = GetOptionalInt(parser, "layer", 0);
1172 if (!layer_or.ok()) {
1173 return layer_or.status();
1174 }
1175
1176 int x = x_or.value();
1177 int y = y_or.value();
1178 int subtype = subtype_or.value();
1179 int layer = layer_or.value();
1180 bool do_write = parser.HasFlag("write");
1181
1182 // Validate ranges
1183 RETURN_IF_ERROR(ValidateSpriteCoord(x, 'X'));
1184 RETURN_IF_ERROR(ValidateSpriteCoord(y, 'Y'));
1185 if (sprite_id < 0 || sprite_id > 0xFF) {
1186 return absl::InvalidArgumentError("Sprite ID must be 0x00-0xFF");
1187 }
1188 if (subtype < 0 || subtype > 0x1F) {
1189 return absl::InvalidArgumentError("Subtype must be 0-31 (5-bit flags)");
1190 }
1191 if (layer < 0 || layer > 1) {
1192 return absl::InvalidArgumentError("Layer must be 0 or 1");
1193 }
1194
1195 // Load room and its sprites
1196 zelda3::Room room = zelda3::LoadRoomHeaderFromRom(rom, room_id);
1197 room.LoadSprites();
1198
1199 int count_before = static_cast<int>(room.GetSprites().size());
1200
1201 // Add the new sprite
1202 room.GetSprites().emplace_back(
1203 static_cast<uint8_t>(sprite_id), static_cast<uint8_t>(x),
1204 static_cast<uint8_t>(y), static_cast<uint8_t>(subtype),
1205 static_cast<uint8_t>(layer));
1206 room.MarkSpritesDirty();
1207
1208 formatter.BeginObject("Place Sprite");
1209 formatter.AddHexField("room_id", room_id, 2);
1210 formatter.AddHexField("sprite_id", sprite_id, 2);
1211 formatter.AddField("sprite_name", zelda3::ResolveSpriteName(sprite_id));
1212 formatter.AddField("x", x);
1213 formatter.AddField("y", y);
1214 formatter.AddField("subtype", subtype);
1215 formatter.AddField("layer", layer);
1216 formatter.AddField("sprites_before", count_before);
1217 formatter.AddField("sprites_after",
1218 static_cast<int>(room.GetSprites().size()));
1219 formatter.AddField("mode", do_write ? "write" : "dry-run");
1220
1221 if (do_write) {
1222 auto save_status = MutateAndSaveRomWithBackup(
1223 rom, formatter, [&room]() { return room.SaveSprites(); });
1224 if (!save_status.ok()) {
1225 formatter.EndObject();
1226 return save_status;
1227 }
1228 }
1229
1230 formatter.EndObject();
1231 return absl::OkStatus();
1232}
1233
1234// ---------------------------------------------------------------------------
1235// dungeon-remove-sprite
1236// ---------------------------------------------------------------------------
1237
1239 Rom* rom, const resources::ArgumentParser& parser,
1240 resources::OutputFormatter& formatter) {
1241 auto room_id_str_or = GetRequiredString(parser, "room");
1242 if (!room_id_str_or.ok()) {
1243 return room_id_str_or.status();
1244 }
1245 const std::string room_id_str = room_id_str_or.value();
1246
1247 int room_id;
1248 if (!ParseHexString(room_id_str, &room_id)) {
1249 return absl::InvalidArgumentError("Invalid room ID. Must be hex.");
1250 }
1251 auto room_status = ValidateRoomId(room_id);
1252 if (!room_status.ok()) {
1253 return room_status;
1254 }
1255
1256 bool do_write = parser.HasFlag("write");
1257
1258 // Load room and its sprites
1259 zelda3::Room room = zelda3::LoadRoomHeaderFromRom(rom, room_id);
1260 room.LoadSprites();
1261
1262 auto& sprites = room.GetSprites();
1263 int count_before = static_cast<int>(sprites.size());
1264
1265 // Find sprite to remove: by --index or by --x/--y position.
1266 const bool has_index = parser.GetString("index").has_value();
1267 const bool has_x = parser.GetString("x").has_value();
1268 const bool has_y = parser.GetString("y").has_value();
1269 if (has_index && (has_x || has_y)) {
1270 return absl::InvalidArgumentError(
1271 "Use either --index or --x/--y, not both");
1272 }
1273 if (!has_index && has_x != has_y) {
1274 return absl::InvalidArgumentError(
1275 "Both --x and --y are required when removing by position");
1276 }
1277 if (!has_index && !has_x) {
1278 return absl::InvalidArgumentError(
1279 "Either --index or both --x and --y are required");
1280 }
1281
1282 int remove_index = -1;
1283 if (has_index) {
1284 auto index_or = GetRequiredInt(parser, "index");
1285 if (!index_or.ok()) {
1286 return index_or.status();
1287 }
1288 remove_index = index_or.value();
1289 } else {
1290 auto x_or = GetRequiredInt(parser, "x");
1291 if (!x_or.ok()) {
1292 return x_or.status();
1293 }
1294 auto y_or = GetRequiredInt(parser, "y");
1295 if (!y_or.ok()) {
1296 return y_or.status();
1297 }
1298
1299 const int x = x_or.value();
1300 const int y = y_or.value();
1301 RETURN_IF_ERROR(ValidateSpriteCoord(x, 'X'));
1302 RETURN_IF_ERROR(ValidateSpriteCoord(y, 'Y'));
1303
1304 for (int i = 0; i < static_cast<int>(sprites.size()); ++i) {
1305 if (sprites[i].x() == x && sprites[i].y() == y) {
1306 remove_index = i;
1307 break;
1308 }
1309 }
1310 if (remove_index < 0) {
1311 return absl::NotFoundError(absl::StrFormat(
1312 "No sprite at (%d, %d) in room 0x%02X", x, y, room_id));
1313 }
1314 }
1315
1316 if (remove_index < 0 || remove_index >= static_cast<int>(sprites.size())) {
1317 return absl::OutOfRangeError(
1318 absl::StrFormat("Sprite index %d out of range (room has %d sprites)",
1319 remove_index, count_before));
1320 }
1321
1322 // Report which sprite we're removing
1323 const auto& target = sprites[remove_index];
1324 formatter.BeginObject("Remove Sprite");
1325 formatter.AddHexField("room_id", room_id, 2);
1326 formatter.AddField("removed_index", remove_index);
1327 formatter.AddHexField("sprite_id", target.id(), 2);
1328 formatter.AddField("sprite_name", zelda3::ResolveSpriteName(target.id()));
1329 formatter.AddField("x", target.x());
1330 formatter.AddField("y", target.y());
1331 formatter.AddField("sprites_before", count_before);
1332
1333 // Remove
1334 sprites.erase(sprites.begin() + remove_index);
1335 room.MarkSpritesDirty();
1336 formatter.AddField("sprites_after", static_cast<int>(sprites.size()));
1337 formatter.AddField("mode", do_write ? "write" : "dry-run");
1338
1339 if (do_write) {
1340 auto save_status = MutateAndSaveRomWithBackup(
1341 rom, formatter, [&room]() { return room.SaveSprites(); });
1342 if (!save_status.ok()) {
1343 formatter.EndObject();
1344 return save_status;
1345 }
1346 }
1347
1348 formatter.EndObject();
1349 return absl::OkStatus();
1350}
1351
1352// ---------------------------------------------------------------------------
1353// dungeon-place-object
1354// ---------------------------------------------------------------------------
1355
1357 Rom* rom, const resources::ArgumentParser& parser,
1358 resources::OutputFormatter& formatter) {
1359 auto room_id_str_or = GetRequiredString(parser, "room");
1360 if (!room_id_str_or.ok()) {
1361 return room_id_str_or.status();
1362 }
1363 auto object_id_str_or = GetRequiredString(parser, "id");
1364 if (!object_id_str_or.ok()) {
1365 return object_id_str_or.status();
1366 }
1367 const std::string room_id_str = room_id_str_or.value();
1368 const std::string object_id_str = object_id_str_or.value();
1369
1370 int room_id, object_id;
1371 if (!ParseHexString(room_id_str, &room_id)) {
1372 return absl::InvalidArgumentError("Invalid room ID. Must be hex.");
1373 }
1374 if (!ParseHexString(object_id_str, &object_id)) {
1375 return absl::InvalidArgumentError("Invalid object ID. Must be hex.");
1376 }
1377 auto room_status = ValidateRoomId(room_id);
1378 if (!room_status.ok()) {
1379 return room_status;
1380 }
1381 if (object_id < 0 || object_id > 0xFFFF) {
1382 return absl::InvalidArgumentError("Object ID must be 0x0000-0xFFFF");
1383 }
1384
1385 auto x_or = GetRequiredInt(parser, "x");
1386 if (!x_or.ok()) {
1387 return x_or.status();
1388 }
1389 auto y_or = GetRequiredInt(parser, "y");
1390 if (!y_or.ok()) {
1391 return y_or.status();
1392 }
1393 auto layer_or = GetOptionalInt(parser, "layer", 0);
1394 if (!layer_or.ok()) {
1395 return layer_or.status();
1396 }
1397
1398 int x = x_or.value();
1399 int y = y_or.value();
1400 // Preserve the command's legacy omitted Type 1 size of zero while deriving
1401 // the fixed, ID-backed size required by Type 2/3 stream entries.
1402 int size = zelda3::CanonicalRoomObjectSize(object_id, 0);
1403 if (parser.GetString("size").has_value()) {
1404 auto size_or = parser.GetInt("size");
1405 if (!size_or.ok()) {
1406 return size_or.status();
1407 }
1408 size = size_or.value();
1409 }
1410 int layer = layer_or.value();
1411 bool do_write = parser.HasFlag("write");
1412
1413 // Validate ranges
1414 if (x < 0 || x > 63) {
1415 return absl::InvalidArgumentError("X must be 0-63");
1416 }
1417 if (y < 0 || y > 63) {
1418 return absl::InvalidArgumentError("Y must be 0-63");
1419 }
1420 if (layer < 0 || layer > 2) {
1421 return absl::InvalidArgumentError("Layer must be 0, 1, or 2");
1422 }
1423 if (size < 0 || size > 0xFF) {
1424 return absl::InvalidArgumentError("Size must be 0-255");
1425 }
1426 const uint8_t canonical_size =
1427 zelda3::CanonicalRoomObjectSize(object_id, static_cast<uint8_t>(size));
1428 if (size != canonical_size) {
1429 return absl::InvalidArgumentError(absl::StrFormat(
1430 "Size %d is not encodable for object 0x%03X; expected %d", size,
1431 object_id, canonical_size));
1432 }
1433
1434 std::optional<ObjectSaveManifestContext> manifest_context;
1435 ASSIGN_OR_RETURN(manifest_context, LoadObjectSaveManifestContext(parser));
1436 const zelda3::DungeonStreamLayout* layout =
1437 manifest_context.has_value() ? &manifest_context->allocator_layout
1438 : nullptr;
1439
1440 // Load room with full objects
1441 zelda3::Room room = zelda3::LoadRoomFromRom(rom, room_id);
1442
1443 int count_before = static_cast<int>(room.GetTileObjects().size());
1444
1445 // Create the new object
1446 zelda3::RoomObject obj(static_cast<int16_t>(object_id),
1447 static_cast<uint8_t>(x), static_cast<uint8_t>(y),
1448 static_cast<uint8_t>(size),
1449 static_cast<uint8_t>(layer));
1450
1451 // Determine type for reporting
1452 int type = zelda3::RoomObject::DetermineObjectType((object_id & 0xFF),
1453 (object_id >> 8));
1454
1455 formatter.BeginObject("Place Object");
1456 formatter.AddHexField("room_id", room_id, 2);
1457 formatter.AddHexField("object_id", object_id, 4);
1458 formatter.AddField("object_name", zelda3::GetObjectName(object_id));
1459 formatter.AddField("object_type", type);
1460 formatter.AddField("x", x);
1461 formatter.AddField("y", y);
1462 formatter.AddField("size", size);
1463 formatter.AddField("layer", layer);
1464 formatter.AddField("objects_before", count_before);
1465 if (const auto manifest_path = parser.GetString("manifest");
1466 manifest_path.has_value()) {
1467 formatter.AddField("manifest", *manifest_path);
1468 formatter.AddField("manifest_write_policy", "block");
1469 }
1470 formatter.AddField("allocator_capability",
1471 layout != nullptr ? "copy_on_write" : "none");
1472
1473 // Add the object
1474 auto add_status = room.AddObject(obj);
1475 if (!add_status.ok()) {
1476 formatter.AddField("error", std::string(add_status.message()));
1477 formatter.EndObject();
1478 return add_status;
1479 }
1480
1481 formatter.AddField("objects_after",
1482 static_cast<int>(room.GetTileObjects().size()));
1483 formatter.AddField("mode", do_write ? "write" : "dry-run");
1484
1485 const auto preflight_status = PreflightObjectSave(
1486 *rom, room_id, obj, room,
1487 manifest_context.has_value() ? &*manifest_context : nullptr);
1488 if (!preflight_status.ok()) {
1489 formatter.AddField("preflight_status", "failed");
1490 formatter.AddField("preflight_error",
1491 std::string(preflight_status.message()));
1492 formatter.EndObject();
1493 return preflight_status;
1494 }
1495 formatter.AddField("preflight_status", "success");
1496
1497 if (do_write) {
1498 auto save_status = MutateAndSaveRomWithBackup(
1499 rom, formatter, [&room, layout]() { return room.SaveObjects(layout); });
1500 if (!save_status.ok()) {
1501 formatter.EndObject();
1502 return save_status;
1503 }
1504 }
1505
1506 formatter.EndObject();
1507 return absl::OkStatus();
1508}
1509
1510// ---------------------------------------------------------------------------
1511// dungeon-remove-object
1512// ---------------------------------------------------------------------------
1513
1515 Rom* rom, const resources::ArgumentParser& parser,
1516 resources::OutputFormatter& formatter) {
1517 int room_id = 0;
1518 ASSIGN_OR_RETURN(room_id, GetRequiredHex(parser, "room"));
1519 RETURN_IF_ERROR(ValidateRoomId(room_id));
1520
1521 int remove_index = 0;
1522 ASSIGN_OR_RETURN(remove_index, GetRequiredInt(parser, "index"));
1523 if (remove_index < 0) {
1524 return absl::InvalidArgumentError("Object index must be non-negative");
1525 }
1526
1527 int expected_id = 0;
1528 ASSIGN_OR_RETURN(expected_id, GetRequiredHex(parser, "expect-id"));
1529 int expected_x = 0;
1530 ASSIGN_OR_RETURN(expected_x, GetRequiredInt(parser, "expect-x"));
1531 int expected_y = 0;
1532 ASSIGN_OR_RETURN(expected_y, GetRequiredInt(parser, "expect-y"));
1533 int expected_size = 0;
1534 ASSIGN_OR_RETURN(expected_size, GetRequiredInt(parser, "expect-size"));
1535 int expected_layer = 0;
1536 ASSIGN_OR_RETURN(expected_layer, GetRequiredInt(parser, "expect-layer"));
1537
1538 if (expected_id < 0 || expected_id > 0xFFF) {
1539 return absl::InvalidArgumentError("Expected object ID must be 0x000-0xFFF");
1540 }
1541 if (expected_x < 0 || expected_x > 63) {
1542 return absl::InvalidArgumentError("Expected X must be 0-63");
1543 }
1544 if (expected_y < 0 || expected_y > 63) {
1545 return absl::InvalidArgumentError("Expected Y must be 0-63");
1546 }
1547 if (expected_size < 0 || expected_size > 0xFF) {
1548 return absl::InvalidArgumentError("Expected size must be 0-255");
1549 }
1550 if (expected_layer < 0 || expected_layer > 2) {
1551 return absl::InvalidArgumentError("Expected layer must be 0, 1, or 2");
1552 }
1553
1554 std::optional<ObjectSaveManifestContext> manifest_context;
1555 ASSIGN_OR_RETURN(manifest_context, LoadObjectSaveManifestContext(parser));
1556 const zelda3::DungeonStreamLayout* layout =
1557 manifest_context.has_value() ? &manifest_context->allocator_layout
1558 : nullptr;
1559
1560 zelda3::Room room = zelda3::LoadRoomFromRom(rom, room_id);
1561 const auto& objects = room.GetTileObjects();
1562 int object_vector_index = -1;
1563 int stream_object_count = 0;
1564 for (int i = 0; i < static_cast<int>(objects.size()); ++i) {
1565 if (!zelda3::UsesRoomObjectStream(objects[i])) {
1566 continue;
1567 }
1568 if (stream_object_count == remove_index) {
1569 object_vector_index = i;
1570 }
1571 ++stream_object_count;
1572 }
1573 if (object_vector_index < 0) {
1574 return absl::OutOfRangeError(
1575 absl::StrFormat("Object index %d out of range (room has %d objects)",
1576 remove_index, stream_object_count));
1577 }
1578
1579 const auto& target = objects[object_vector_index];
1580 if ((target.options() & zelda3::ObjectOption::Chest) !=
1582 return absl::FailedPreconditionError(
1583 "Removing chest objects requires coordinated chest-table editing");
1584 }
1585
1586 if (target.id_ != expected_id || target.x() != expected_x ||
1587 target.y() != expected_y || target.size_ != expected_size ||
1588 target.GetLayerValue() != expected_layer) {
1589 return absl::FailedPreconditionError(
1590 absl::StrFormat("Object guard mismatch at index %d: expected 0x%03X at "
1591 "(%d,%d) size=%d layer=%d; found 0x%03X at (%d,%d) "
1592 "size=%d layer=%d",
1593 remove_index, expected_id, expected_x, expected_y,
1594 expected_size, expected_layer, target.id_, target.x(),
1595 target.y(), target.size_, target.GetLayerValue()));
1596 }
1597
1598 const int object_id = target.id_;
1599 const int object_x = target.x();
1600 const int object_y = target.y();
1601 const int object_size = target.size_;
1602 const int object_layer = target.GetLayerValue();
1603 const auto encoded = target.EncodeObjectToBytes();
1604 const int object_type =
1605 zelda3::RoomObject::DetermineObjectType(encoded.b1, encoded.b3);
1606
1607 formatter.BeginObject("Remove Object");
1608 formatter.AddHexField("room_id", room_id, 2);
1609 formatter.AddField("removed_index", remove_index);
1610 formatter.AddHexField("object_id", object_id, 3);
1611 formatter.AddField("object_name", zelda3::GetObjectName(object_id));
1612 formatter.AddField("object_type", object_type);
1613 formatter.AddField("x", object_x);
1614 formatter.AddField("y", object_y);
1615 formatter.AddField("size", object_size);
1616 formatter.AddField("layer", object_layer);
1617 formatter.AddField("objects_before", stream_object_count);
1618 if (const auto manifest_path = parser.GetString("manifest");
1619 manifest_path.has_value()) {
1620 formatter.AddField("manifest", *manifest_path);
1621 formatter.AddField("manifest_write_policy", "block");
1622 }
1623 formatter.AddField("allocator_capability",
1624 layout != nullptr ? "copy_on_write" : "none");
1625
1626 const absl::Status remove_status = room.RemoveObject(object_vector_index);
1627 if (!remove_status.ok()) {
1628 formatter.AddField("remove_error", std::string(remove_status.message()));
1629 formatter.EndObject();
1630 return remove_status;
1631 }
1632 formatter.AddField("objects_after", stream_object_count - 1);
1633 const bool do_write = parser.HasFlag("write");
1634 formatter.AddField("mode", do_write ? "write" : "dry-run");
1635
1636 const absl::Status preflight_status = PreflightObjectRemoval(
1637 *rom, room_id, object_vector_index, room,
1638 manifest_context.has_value() ? &*manifest_context : nullptr);
1639 if (!preflight_status.ok()) {
1640 formatter.AddField("preflight_status", "failed");
1641 formatter.AddField("preflight_error",
1642 std::string(preflight_status.message()));
1643 formatter.EndObject();
1644 return preflight_status;
1645 }
1646 formatter.AddField("preflight_status", "success");
1647
1648 if (do_write) {
1649 const absl::Status save_status = MutateAndSaveRomWithBackup(
1650 rom, formatter, [&room, layout]() { return room.SaveObjects(layout); });
1651 if (!save_status.ok()) {
1652 formatter.EndObject();
1653 return save_status;
1654 }
1655 }
1656
1657 formatter.EndObject();
1658 return absl::OkStatus();
1659}
1660
1661// ---------------------------------------------------------------------------
1662// dungeon-get-palette
1663// ---------------------------------------------------------------------------
1664
1666 Rom* rom, const resources::ArgumentParser& parser,
1667 resources::OutputFormatter& formatter) {
1668 int room_id = 0;
1669 ASSIGN_OR_RETURN(room_id, GetRequiredHex(parser, "room"));
1670 RETURN_IF_ERROR(ValidateRoomId(room_id));
1671
1672 std::optional<int> requested_index;
1673 if (parser.GetString("index").has_value()) {
1674 int color_index = 0;
1675 ASSIGN_OR_RETURN(color_index, GetRequiredInt(parser, "index"));
1676 RETURN_IF_ERROR(ValidateDungeonPaletteColorIndex(color_index));
1677 requested_index = color_index;
1678 }
1679
1680 DungeonPaletteSelection selection;
1681 ASSIGN_OR_RETURN(selection, ResolveDungeonPaletteSelection(*rom, room_id));
1682
1683 formatter.BeginObject("Dungeon Palette");
1684 AddDungeonPaletteScope(formatter, selection);
1685 formatter.AddField("color_count", kDungeonPaletteColorCount);
1686
1687 if (requested_index.has_value()) {
1688 const uint32_t color_pc =
1689 DungeonPaletteColorPc(selection.selected, *requested_index);
1690 uint16_t color = 0;
1691 ASSIGN_OR_RETURN(color, ReadDungeonPaletteColor(*rom, selection.selected,
1692 *requested_index));
1693 formatter.AddField("color_index", *requested_index);
1694 formatter.AddHexField("color_pc", color_pc, 6);
1695 formatter.AddHexField("color_snes_address", PcToSnes(color_pc), 6);
1696 formatter.AddHexField("color_snes", color, 4);
1697 } else {
1698 formatter.BeginArray("colors");
1699 for (int color_index = 0; color_index < kDungeonPaletteColorCount;
1700 ++color_index) {
1701 const uint32_t color_pc =
1702 DungeonPaletteColorPc(selection.selected, color_index);
1703 uint16_t color = 0;
1704 ASSIGN_OR_RETURN(color, ReadDungeonPaletteColor(*rom, selection.selected,
1705 color_index));
1706 formatter.BeginObject();
1707 formatter.AddField("index", color_index);
1708 formatter.AddHexField("pc", color_pc, 6);
1709 formatter.AddHexField("snes_address", PcToSnes(color_pc), 6);
1710 formatter.AddHexField("snes_color", color, 4);
1711 formatter.EndObject();
1712 }
1713 formatter.EndArray();
1714 }
1715
1716 formatter.AddField("status", "success");
1717 formatter.EndObject();
1718 return absl::OkStatus();
1719}
1720
1721// ---------------------------------------------------------------------------
1722// dungeon-set-palette-color
1723// ---------------------------------------------------------------------------
1724
1726 Rom* rom, const resources::ArgumentParser& parser,
1727 resources::OutputFormatter& formatter) {
1728 const bool do_write = parser.HasFlag("write");
1729#if defined(__EMSCRIPTEN__)
1730 if (do_write) {
1731 return absl::FailedPreconditionError(
1732 "dungeon-set-palette-color --write is unavailable in the browser "
1733 "terminal because durable filesystem sync cannot be guaranteed; use "
1734 "native z3ed on a disposable ROM copy");
1735 }
1736#endif
1737 if (do_write && rom->filename().empty()) {
1738 return absl::FailedPreconditionError(
1739 "dungeon-set-palette-color --write requires a ROM loaded from a "
1740 "filesystem path");
1741 }
1742
1743 int room_id = 0;
1744 int color_index = 0;
1745 int expected_palette_set = 0;
1746 int expected_palette_index = 0;
1747 int expected_color = 0;
1748 int replacement_color = 0;
1749 ASSIGN_OR_RETURN(room_id, GetRequiredHex(parser, "room"));
1750 ASSIGN_OR_RETURN(color_index, GetRequiredInt(parser, "index"));
1751 ASSIGN_OR_RETURN(expected_palette_set,
1752 GetRequiredHex(parser, "expect-palette-set"));
1753 ASSIGN_OR_RETURN(expected_palette_index,
1754 GetRequiredInt(parser, "expect-palette-index"));
1755 ASSIGN_OR_RETURN(expected_color, GetRequiredHex(parser, "expect-color"));
1756 ASSIGN_OR_RETURN(replacement_color, GetRequiredHex(parser, "color"));
1757
1758 RETURN_IF_ERROR(ValidateRoomId(room_id));
1759 RETURN_IF_ERROR(ValidateDungeonPaletteColorIndex(color_index));
1760 if (expected_palette_set < 0 || expected_palette_set > 0xFF) {
1761 return absl::InvalidArgumentError(
1762 "--expect-palette-set must be an 8-bit value in range 0x00-0xFF");
1763 }
1764 if (expected_palette_index < 0 ||
1765 expected_palette_index >= kDungeonPaletteCount) {
1766 return absl::InvalidArgumentError(
1767 absl::StrFormat("--expect-palette-index must be in range 0-%d",
1768 kDungeonPaletteCount - 1));
1769 }
1770 RETURN_IF_ERROR(ValidateSnes15BitColor(expected_color, "expect-color"));
1771 RETURN_IF_ERROR(ValidateSnes15BitColor(replacement_color, "color"));
1772
1773 const std::string manifest_path = parser.GetString("manifest").value();
1774 core::HackManifest manifest;
1775 RETURN_IF_ERROR(manifest.LoadFromFile(manifest_path));
1776
1777 DungeonPaletteSelection selection;
1778 ASSIGN_OR_RETURN(selection, ResolveDungeonPaletteSelection(*rom, room_id));
1779 if (selection.selected.palette_set_id != expected_palette_set) {
1780 return absl::FailedPreconditionError(absl::StrFormat(
1781 "Palette-set CAS failed for room 0x%03X: expected full 8-bit ID "
1782 "0x%02X, got 0x%02X",
1783 room_id, expected_palette_set, selection.selected.palette_set_id));
1784 }
1785 if (selection.selected.palette_index != expected_palette_index) {
1786 return absl::FailedPreconditionError(absl::StrFormat(
1787 "Resolved palette CAS failed for room 0x%03X: expected %d, got %d",
1788 room_id, expected_palette_index, selection.selected.palette_index));
1789 }
1790
1791 uint16_t actual_color = 0;
1792 ASSIGN_OR_RETURN(actual_color, ReadDungeonPaletteColor(
1793 *rom, selection.selected, color_index));
1794 if (actual_color != expected_color) {
1795 return absl::FailedPreconditionError(absl::StrFormat(
1796 "Color CAS failed at palette %d index %d: expected 0x%04X, got "
1797 "0x%04X",
1798 selection.selected.palette_index, color_index, expected_color,
1799 actual_color));
1800 }
1801 if (replacement_color == actual_color) {
1802 return absl::InvalidArgumentError(absl::StrFormat(
1803 "Refusing no-op dungeon palette write: color is already 0x%04X",
1804 actual_color));
1805 }
1806
1807 const uint32_t color_pc =
1808 DungeonPaletteColorPc(selection.selected, color_index);
1809 RETURN_IF_ERROR(ValidateDungeonPaletteManifestRange(manifest, color_pc));
1810 if (do_write) {
1811 RETURN_IF_ERROR(ValidateDungeonPaletteWriteBaseline(*rom));
1812 }
1813
1814 formatter.BeginObject("Set Dungeon Palette Color");
1815 AddDungeonPaletteScope(formatter, selection);
1816 formatter.AddField("manifest", manifest_path);
1817 formatter.AddField("manifest_write_policy", "block");
1818 formatter.AddField("mode", do_write ? "write" : "dry-run");
1819 formatter.AddField("color_index", color_index);
1820 formatter.AddHexField("color_pc", color_pc, 6);
1821 formatter.AddHexField("color_snes_address", PcToSnes(color_pc), 6);
1822 formatter.AddHexField("old_color", actual_color, 4);
1823 formatter.AddHexField("new_color", replacement_color, 4);
1824 formatter.AddField("preflight_status", "success");
1825
1826 if (!do_write) {
1827 formatter.AddField("status", "dry-run");
1828 formatter.EndObject();
1829 return absl::OkStatus();
1830 }
1831
1832 rom::WriteFence write_fence;
1834 write_fence.Allow(color_pc, color_pc + 2u, "DungeonPaletteColor"));
1835 const std::vector<uint8_t> before = rom->vector();
1836 ScopedRomTransaction transaction(*rom);
1837 {
1838 rom::ScopedWriteFence scoped_fence(rom, &write_fence);
1840 rom->WriteShort(color_pc, static_cast<uint16_t>(replacement_color)));
1841 }
1842
1843 std::vector<uint32_t> changed_bytes;
1844 ASSIGN_OR_RETURN(changed_bytes,
1845 ValidateDungeonPaletteWholeRomDiff(before, *rom, color_pc));
1846 uint16_t memory_color = 0;
1847 ASSIGN_OR_RETURN(memory_color, ReadDungeonPaletteColor(
1848 *rom, selection.selected, color_index));
1849 if (memory_color != replacement_color) {
1850 return absl::DataLossError(
1851 "In-memory dungeon palette readback did not match the replacement");
1852 }
1853 formatter.AddField("write_status", "success");
1854 formatter.AddField("whole_rom_diff_status", "verified");
1855 formatter.AddField("changed_byte_count",
1856 static_cast<int>(changed_bytes.size()));
1857 formatter.BeginArray("changed_byte_pcs");
1858 for (uint32_t changed_pc : changed_bytes) {
1859 formatter.AddArrayItem(absl::StrFormat("0x%06X", changed_pc));
1860 }
1861 formatter.EndArray();
1862 formatter.AddField("memory_readback_status", "verified");
1863
1864 const absl::Status save_status = SaveRomWithBackup(rom, formatter);
1865 if (!save_status.ok()) {
1866 formatter.AddField("status", "error");
1867 formatter.EndObject();
1868 return save_status;
1869 }
1870
1871 // The atomic disk replacement has succeeded. Commit the caller's in-memory
1872 // transaction before external verification so a later reopen/readback
1873 // failure cannot roll memory back while leaving the persisted file changed.
1874 transaction.Commit();
1875 const absl::Status reopen_status = VerifyDungeonPaletteExternalReadback(
1876 *rom, room_id, expected_palette_set, expected_palette_index, color_index,
1877 static_cast<uint16_t>(replacement_color));
1878 if (!reopen_status.ok()) {
1879 formatter.AddField("readback_status", "external_reopen_failed");
1880 formatter.AddField("readback_error", std::string(reopen_status.message()));
1881 formatter.AddField("status", "error");
1882 formatter.EndObject();
1883 return reopen_status;
1884 }
1885
1886 formatter.AddField("readback_status", "external_reopen_verified");
1887 formatter.AddField("status", "success");
1888 formatter.EndObject();
1889 return absl::OkStatus();
1890}
1891
1892// ---------------------------------------------------------------------------
1893// dungeon-set-door-type
1894// ---------------------------------------------------------------------------
1895
1897 Rom* rom, const resources::ArgumentParser& parser,
1898 resources::OutputFormatter& formatter) {
1899 int room_id = 0;
1900 ASSIGN_OR_RETURN(room_id, GetRequiredHex(parser, "room"));
1901 RETURN_IF_ERROR(ValidateRoomId(room_id));
1902
1903 int x = 0;
1904 int y = 0;
1905 ASSIGN_OR_RETURN(x, GetRequiredInt(parser, "x"));
1906 ASSIGN_OR_RETURN(y, GetRequiredInt(parser, "y"));
1907 if (x < 0 || x > 63 || y < 0 || y > 63) {
1908 return absl::InvalidArgumentError(
1909 "Door tile coordinates must be in range 0-63");
1910 }
1911
1912 int new_type = 0;
1913 int expected_type = 0;
1914 ASSIGN_OR_RETURN(new_type, GetRequiredHex(parser, "type"));
1915 ASSIGN_OR_RETURN(expected_type, GetRequiredHex(parser, "expect-type"));
1916 RETURN_IF_ERROR(ValidateDoorType(new_type, "type"));
1917 RETURN_IF_ERROR(ValidateDoorType(expected_type, "expect-type"));
1918
1919 std::optional<ObjectSaveManifestContext> manifest_context;
1920 ASSIGN_OR_RETURN(manifest_context, LoadObjectSaveManifestContext(parser));
1921 if (!manifest_context.has_value()) {
1922 return absl::FailedPreconditionError(
1923 "--manifest is required for dungeon-set-door-type");
1924 }
1925
1926 DoorTypeTarget target;
1928 target, ResolveDoorTypeTarget(rom, room_id, x, y, *manifest_context));
1929 const int old_type = static_cast<int>(target.door.type);
1930 if (old_type != expected_type) {
1931 return absl::FailedPreconditionError(absl::StrFormat(
1932 "Door type compare-and-swap failed at (%d, %d): expected 0x%02X, "
1933 "found 0x%02X",
1934 x, y, expected_type, old_type));
1935 }
1936 if (new_type == old_type) {
1937 return absl::InvalidArgumentError(
1938 "--type must differ from the current door type");
1939 }
1940
1941 const bool do_write = parser.HasFlag("write");
1942 if (do_write && rom->filename().empty()) {
1943 return absl::FailedPreconditionError("Write mode requires a ROM filename");
1944 }
1945
1946 formatter.BeginObject("Set Door Type");
1947 formatter.AddHexField("room_id", room_id, 3);
1948 formatter.AddField("door_index", target.door_index);
1949 formatter.AddField("x", x);
1950 formatter.AddField("y", y);
1951 formatter.AddField("position", target.door.position);
1952 formatter.AddField("direction", std::string(target.door.GetDirectionName()));
1953 formatter.AddHexField("old_type", old_type, 2);
1954 formatter.AddField("old_type_name", std::string(target.door.GetTypeName()));
1955 formatter.AddHexField("new_type", new_type, 2);
1956 formatter.AddField(
1957 "new_type_name",
1958 std::string(zelda3::GetDoorTypeName(
1959 zelda3::DoorTypeFromRaw(static_cast<uint8_t>(new_type)))));
1960 formatter.AddHexField("object_stream_pc", target.object_stream_pc, 6);
1961 formatter.AddHexField("object_stream_end_pc", target.object_stream_end_pc, 6);
1962 formatter.AddHexField("door_pointer_pc", target.door_pointer_pc, 6);
1963 formatter.AddHexField("door_entry_pc", target.door_entry_pc, 6);
1964 formatter.AddHexField("type_pc", target.type_pc, 6);
1965 formatter.AddHexField("type_snes", PcToSnes(target.type_pc), 6);
1966 formatter.AddField("manifest", *parser.GetString("manifest"));
1967 formatter.AddField("manifest_write_policy", "block");
1968 formatter.AddField("stream_alias_status", "unique");
1969 formatter.AddField("mode", do_write ? "write" : "dry-run");
1970 formatter.AddField("preflight_status", "success");
1971
1972 if (!do_write) {
1973 formatter.AddField("write_status", "not_requested");
1974 formatter.AddField("save_status", "not_requested");
1975 formatter.AddField("readback_status", "not_requested");
1976 formatter.AddField("status", "success");
1977 formatter.EndObject();
1978 return absl::OkStatus();
1979 }
1980
1981 const auto finish_error = [&formatter](const absl::Status& status,
1982 const char* field) -> absl::Status {
1983 formatter.AddField("status", "error");
1984 formatter.AddField(field, std::string(status.message()));
1985 formatter.EndObject();
1986 return status;
1987 };
1988 const auto verify_readback =
1989 [&target, new_type](const DoorTypeTarget& readback) -> absl::Status {
1990 if (readback.door_index != target.door_index ||
1991 readback.type_pc != target.type_pc ||
1992 readback.door.position != target.door.position ||
1993 readback.door.direction != target.door.direction ||
1994 static_cast<int>(readback.door.type) != new_type) {
1995 return absl::DataLossError(
1996 "Door type readback does not match the requested change");
1997 }
1998 return absl::OkStatus();
1999 };
2000
2001 rom::WriteFence write_fence;
2002 const absl::Status allow_status = write_fence.Allow(
2003 target.type_pc, target.type_pc + 1u, "dungeon door type");
2004 if (!allow_status.ok()) {
2005 return finish_error(allow_status, "write_error");
2006 }
2007
2008 const std::vector<uint8_t> before = rom->vector();
2009 ScopedRomTransaction transaction(*rom);
2010 {
2011 rom::ScopedWriteFence write_scope(rom, &write_fence);
2012 const absl::Status write_status = rom->WriteByte(
2013 static_cast<int>(target.type_pc), static_cast<uint8_t>(new_type));
2014 if (!write_status.ok()) {
2015 return finish_error(write_status, "write_error");
2016 }
2017 }
2018
2019 if (rom->size() != before.size()) {
2020 return finish_error(
2021 absl::DataLossError("Door type edit unexpectedly resized the ROM"),
2022 "write_error");
2023 }
2024 int changed_bytes = 0;
2025 uint32_t changed_pc = 0;
2026 for (uint32_t pc = 0; pc < before.size(); ++pc) {
2027 if (before[pc] != rom->data()[pc]) {
2028 ++changed_bytes;
2029 changed_pc = pc;
2030 }
2031 }
2032 if (changed_bytes != 1 || changed_pc != target.type_pc) {
2033 return finish_error(
2034 absl::DataLossError(absl::StrFormat(
2035 "Door type edit changed %d bytes; expected only PC 0x%06X",
2036 changed_bytes, target.type_pc)),
2037 "write_error");
2038 }
2039
2040 auto memory_readback =
2041 ResolveDoorTypeTarget(rom, room_id, x, y, *manifest_context);
2042 if (!memory_readback.ok()) {
2043 return finish_error(memory_readback.status(), "readback_error");
2044 }
2045 const absl::Status memory_verify = verify_readback(*memory_readback);
2046 if (!memory_verify.ok()) {
2047 return finish_error(memory_verify, "readback_error");
2048 }
2049 formatter.AddField("readback_status", "pre_save_verified");
2050 formatter.AddField("write_status", "success");
2051
2052 const absl::Status save_status = SaveRomWithBackup(rom, formatter);
2053 if (!save_status.ok()) {
2054 formatter.AddField("status", "error");
2055 formatter.EndObject();
2056 return save_status;
2057 }
2058
2059 transaction.Commit();
2060 formatter.AddField("status", "success");
2061 formatter.EndObject();
2062 return absl::OkStatus();
2063}
2064
2065// ---------------------------------------------------------------------------
2066// dungeon-list-pot-items
2067// ---------------------------------------------------------------------------
2068
2070 Rom* rom, const resources::ArgumentParser& parser,
2071 resources::OutputFormatter& formatter) {
2072 int room_id = 0;
2073 ASSIGN_OR_RETURN(room_id, GetRequiredHex(parser, "room"));
2074 RETURN_IF_ERROR(ValidateRoomId(room_id));
2075
2076 PotItemStreamView stream;
2077 ASSIGN_OR_RETURN(stream, ResolveRuntimePotItemStream(rom, room_id));
2078
2079 formatter.BeginObject("Dungeon Pot Items");
2080 formatter.AddHexField("room_id", room_id, 3);
2081 formatter.AddHexField("pointer_slot_pc", stream.pointer_slot_pc, 6);
2082 formatter.AddHexField("stream_pc", stream.stream_pc, 6);
2083 formatter.AddHexField("stream_end_pc", stream.stream_end_pc, 6);
2084 formatter.AddField("item_count", static_cast<int>(stream.entries.size()));
2085 formatter.BeginArray("items");
2086 for (const PotItemEntryView& entry : stream.entries) {
2087 formatter.BeginObject();
2088 formatter.AddField("index", entry.index);
2089 formatter.AddHexField("position", entry.item.position, 4);
2090 formatter.AddField("tile_x", entry.item.GetTileX());
2091 formatter.AddField("tile_y", entry.item.GetTileY());
2092 formatter.AddHexField("item_id", entry.item.item, 2);
2093 formatter.AddHexField("item_pc", entry.item_pc, 6);
2094 formatter.AddHexField("item_snes", PcToSnes(entry.item_pc), 6);
2095 formatter.EndObject();
2096 }
2097 formatter.EndArray();
2098 formatter.AddField("status", "success");
2099 formatter.EndObject();
2100 return absl::OkStatus();
2101}
2102
2103// ---------------------------------------------------------------------------
2104// dungeon-set-pot-item
2105// ---------------------------------------------------------------------------
2106
2108 Rom* rom, const resources::ArgumentParser& parser,
2109 resources::OutputFormatter& formatter) {
2110 int room_id = 0;
2111 int index = 0;
2112 int expected_position = 0;
2113 int expected_item = 0;
2114 int new_item = 0;
2115 ASSIGN_OR_RETURN(room_id, GetRequiredHex(parser, "room"));
2116 RETURN_IF_ERROR(ValidateRoomId(room_id));
2117 ASSIGN_OR_RETURN(index, GetRequiredInt(parser, "index"));
2118 ASSIGN_OR_RETURN(expected_position,
2119 GetRequiredHex(parser, "expect-position"));
2120 ASSIGN_OR_RETURN(expected_item, GetRequiredHex(parser, "expect-item"));
2121 ASSIGN_OR_RETURN(new_item, GetRequiredHex(parser, "item"));
2122 if (expected_position < 0 || expected_position > 0xFFFF) {
2123 return absl::InvalidArgumentError(
2124 "--expect-position must be in range 0x0000-0xFFFF");
2125 }
2126 if (expected_item < 0 || expected_item > 0xFF) {
2127 return absl::InvalidArgumentError(
2128 "--expect-item must be in range 0x00-0xFF");
2129 }
2130 if (new_item < 0 || new_item > 0xFF) {
2131 return absl::InvalidArgumentError("--item must be in range 0x00-0xFF");
2132 }
2133
2134 PotItemManifestContext manifest_context;
2135 ASSIGN_OR_RETURN(manifest_context, LoadPotItemManifestContext(parser));
2136 PotItemTarget target;
2137 ASSIGN_OR_RETURN(target,
2138 ResolvePotItemTarget(rom, room_id, index, manifest_context));
2139 if (target.entry.item.position != expected_position ||
2140 target.entry.item.item != expected_item) {
2141 return absl::FailedPreconditionError(absl::StrFormat(
2142 "Pot-item compare-and-swap failed at index %d: expected "
2143 "position/item 0x%04X/0x%02X, found 0x%04X/0x%02X",
2144 index, expected_position, expected_item, target.entry.item.position,
2145 target.entry.item.item));
2146 }
2147 if (new_item == expected_item) {
2148 return absl::InvalidArgumentError(
2149 "--item must differ from the current pot item");
2150 }
2151
2152 const bool do_write = parser.HasFlag("write");
2153 if (do_write && rom->filename().empty()) {
2154 return absl::FailedPreconditionError("Write mode requires a ROM filename");
2155 }
2156
2157 formatter.BeginObject("Set Pot Item");
2158 formatter.AddHexField("room_id", room_id, 3);
2159 formatter.AddField("index", index);
2160 formatter.AddHexField("position", target.entry.item.position, 4);
2161 formatter.AddField("tile_x", target.entry.item.GetTileX());
2162 formatter.AddField("tile_y", target.entry.item.GetTileY());
2163 formatter.AddHexField("old_item", expected_item, 2);
2164 formatter.AddHexField("new_item", new_item, 2);
2165 formatter.AddHexField("pointer_slot_pc", target.pointer_slot_pc, 6);
2166 formatter.AddHexField("stream_pc", target.stream_pc, 6);
2167 formatter.AddHexField("stream_end_pc", target.stream_end_pc, 6);
2168 formatter.AddHexField("item_pc", target.entry.item_pc, 6);
2169 formatter.AddHexField("item_snes", PcToSnes(target.entry.item_pc), 6);
2170 formatter.AddField("manifest", *parser.GetString("manifest"));
2171 formatter.AddField("manifest_write_policy", "block");
2172 formatter.AddField("stream_alias_status", "unique");
2173 formatter.AddField("mode", do_write ? "write" : "dry-run");
2174 formatter.AddField("preflight_status", "success");
2175
2176 if (!do_write) {
2177 formatter.AddField("write_status", "not_requested");
2178 formatter.AddField("save_status", "not_requested");
2179 formatter.AddField("readback_status", "not_requested");
2180 formatter.AddField("status", "success");
2181 formatter.EndObject();
2182 return absl::OkStatus();
2183 }
2184
2185 const auto finish_error = [&formatter](const absl::Status& status,
2186 const char* field) -> absl::Status {
2187 formatter.AddField("status", "error");
2188 formatter.AddField(field, std::string(status.message()));
2189 formatter.EndObject();
2190 return status;
2191 };
2192 const auto verify_readback =
2193 [&target, new_item](const PotItemTarget& readback) -> absl::Status {
2194 if (readback.entry.index != target.entry.index ||
2195 readback.entry.entry_pc != target.entry.entry_pc ||
2196 readback.entry.item_pc != target.entry.item_pc ||
2197 readback.entry.item.position != target.entry.item.position ||
2198 readback.entry.item.item != new_item ||
2199 readback.stream_pc != target.stream_pc ||
2200 readback.stream_end_pc != target.stream_end_pc) {
2201 return absl::DataLossError(
2202 "Pot-item readback does not match the requested change");
2203 }
2204 return absl::OkStatus();
2205 };
2206
2207 rom::WriteFence write_fence;
2208 const absl::Status allow_status = write_fence.Allow(
2209 target.entry.item_pc, target.entry.item_pc + 1u, "dungeon pot item");
2210 if (!allow_status.ok()) {
2211 return finish_error(allow_status, "write_error");
2212 }
2213
2214 const std::vector<uint8_t> before = rom->vector();
2215 ScopedRomTransaction transaction(*rom);
2216 {
2217 rom::ScopedWriteFence write_scope(rom, &write_fence);
2218 const absl::Status write_status = rom->WriteByte(
2219 static_cast<int>(target.entry.item_pc), static_cast<uint8_t>(new_item));
2220 if (!write_status.ok()) {
2221 return finish_error(write_status, "write_error");
2222 }
2223 }
2224
2225 if (rom->size() != before.size()) {
2226 return finish_error(
2227 absl::DataLossError("Pot-item edit unexpectedly resized the ROM"),
2228 "write_error");
2229 }
2230 int changed_bytes = 0;
2231 uint32_t changed_pc = 0;
2232 for (uint32_t pc = 0; pc < before.size(); ++pc) {
2233 if (before[pc] != rom->data()[pc]) {
2234 ++changed_bytes;
2235 changed_pc = pc;
2236 }
2237 }
2238 if (changed_bytes != 1 || changed_pc != target.entry.item_pc) {
2239 return finish_error(
2240 absl::DataLossError(absl::StrFormat(
2241 "Pot-item edit changed %d bytes; expected only PC 0x%06X",
2242 changed_bytes, target.entry.item_pc)),
2243 "write_error");
2244 }
2245
2246 auto memory_readback =
2247 ResolvePotItemTarget(rom, room_id, index, manifest_context);
2248 if (!memory_readback.ok()) {
2249 return finish_error(memory_readback.status(), "readback_error");
2250 }
2251 const absl::Status memory_verify = verify_readback(*memory_readback);
2252 if (!memory_verify.ok()) {
2253 return finish_error(memory_verify, "readback_error");
2254 }
2255 formatter.AddField("readback_status", "pre_save_verified");
2256 formatter.AddField("write_status", "success");
2257
2258 const absl::Status save_status = SaveRomWithBackup(rom, formatter);
2259 if (!save_status.ok()) {
2260 formatter.AddField("status", "error");
2261 formatter.EndObject();
2262 return save_status;
2263 }
2264
2265 transaction.Commit();
2266 formatter.AddField("status", "success");
2267 formatter.EndObject();
2268 return absl::OkStatus();
2269}
2270
2271// ---------------------------------------------------------------------------
2272// dungeon-set-collision-tile
2273// ---------------------------------------------------------------------------
2274
2276 Rom* rom, const resources::ArgumentParser& parser,
2277 resources::OutputFormatter& formatter) {
2278 auto room_id_str_or = GetRequiredString(parser, "room");
2279 if (!room_id_str_or.ok()) {
2280 return room_id_str_or.status();
2281 }
2282 auto tiles_str_or = GetRequiredString(parser, "tiles");
2283 if (!tiles_str_or.ok()) {
2284 return tiles_str_or.status();
2285 }
2286 const std::string room_id_str = room_id_str_or.value();
2287 const std::string tiles_str = tiles_str_or.value();
2288
2289 int room_id;
2290 if (!ParseHexString(room_id_str, &room_id)) {
2291 return absl::InvalidArgumentError("Invalid room ID. Must be hex.");
2292 }
2293 auto room_status = ValidateRoomId(room_id);
2294 if (!room_status.ok()) {
2295 return room_status;
2296 }
2297
2298 bool do_write = parser.HasFlag("write");
2299
2300 // Parse tile specifications: "x,y,tile;x,y,tile;..."
2301 struct TileSpec {
2302 int x, y, tile;
2303 };
2304 std::vector<TileSpec> specs;
2305
2306 for (absl::string_view entry :
2307 absl::StrSplit(tiles_str, ';', absl::SkipEmpty())) {
2308 std::vector<std::string> parts =
2309 absl::StrSplit(entry, ',', absl::SkipEmpty());
2310 if (parts.size() != 3) {
2311 return absl::InvalidArgumentError(absl::StrFormat(
2312 "Invalid tile spec '%s'. Expected x,y,tile (e.g. 10,5,0xB7)", entry));
2313 }
2314 TileSpec spec;
2315
2316 // x and y are decimal tile coords
2317 if (!absl::SimpleAtoi(parts[0], &spec.x)) {
2318 return absl::InvalidArgumentError(
2319 absl::StrFormat("Invalid X coord '%s'", parts[0]));
2320 }
2321 if (!absl::SimpleAtoi(parts[1], &spec.y)) {
2322 return absl::InvalidArgumentError(
2323 absl::StrFormat("Invalid Y coord '%s'", parts[1]));
2324 }
2325 if (!ParseHexString(parts[2], &spec.tile)) {
2326 return absl::InvalidArgumentError(
2327 absl::StrFormat("Invalid tile value '%s'. Must be hex.", parts[2]));
2328 }
2329
2330 // Validate ranges
2331 if (spec.x < 0 || spec.x > 63 || spec.y < 0 || spec.y > 63) {
2332 return absl::InvalidArgumentError(absl::StrFormat(
2333 "Tile coords (%d,%d) out of range (0-63)", spec.x, spec.y));
2334 }
2335 if (spec.tile < 0 || spec.tile > 0xFF) {
2336 return absl::InvalidArgumentError("Tile value must be 0x00-0xFF");
2337 }
2338
2339 specs.push_back(spec);
2340 }
2341
2342 if (specs.empty()) {
2343 return absl::InvalidArgumentError("No tile specs provided");
2344 }
2345
2346 // Load room with custom collision data
2347 zelda3::Room room = zelda3::LoadRoomFromRom(rom, room_id);
2348
2349 formatter.BeginObject("Set Collision Tiles");
2350 formatter.AddHexField("room_id", room_id, 2);
2351 formatter.AddField("had_custom_collision", room.has_custom_collision());
2352 formatter.AddField("tile_count", static_cast<int>(specs.size()));
2353 formatter.AddField("mode", do_write ? "write" : "dry-run");
2354
2355 // Apply each tile change
2356 formatter.BeginArray("changes");
2357 for (const auto& spec : specs) {
2358 uint8_t old_value = room.GetCollisionTile(spec.x, spec.y);
2359 room.SetCollisionTile(spec.x, spec.y, static_cast<uint8_t>(spec.tile));
2360
2361 formatter.BeginObject();
2362 formatter.AddField("x", spec.x);
2363 formatter.AddField("y", spec.y);
2364 formatter.AddHexField("old_tile", old_value, 2);
2365 formatter.AddHexField("new_tile", spec.tile, 2);
2366 formatter.EndObject();
2367 }
2368 formatter.EndArray();
2369
2370 if (do_write) {
2371 auto save_status =
2372 MutateAndSaveRomWithBackup(rom, formatter, [&rom, &room]() mutable {
2373 // Flush collision changes to ROM.
2374 std::array<zelda3::Room, 1> rooms_arr = {std::move(room)};
2375 return zelda3::SaveAllCollision(rom, absl::MakeSpan(rooms_arr));
2376 });
2377 if (!save_status.ok()) {
2378 formatter.EndObject();
2379 return save_status;
2380 }
2381 }
2382
2383 formatter.EndObject();
2384 return absl::OkStatus();
2385}
2386
2387} // namespace handlers
2388} // namespace cli
2389} // 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:28
absl::Status LoadFromFile(const std::string &filename, const LoadOptions &options=LoadOptions::Defaults())
Definition rom.cc:270
auto filename() const
Definition rom.h:175
absl::Status WriteByte(int addr, uint8_t value)
Definition rom.cc:631
absl::StatusOr< uint8_t > ReadByte(int offset) const
Definition rom.cc:563
const auto & vector() const
Definition rom.h:173
absl::Status SaveToFile(const SaveSettings &settings)
Definition rom.cc:416
absl::StatusOr< uint16_t > ReadWord(int offset) const
Definition rom.cc:571
auto data() const
Definition rom.h:169
auto size() const
Definition rom.h:168
bool dirty() const
Definition rom.h:156
absl::Status LoadFromData(const std::vector< uint8_t > &data, const LoadOptions &options=LoadOptions::Defaults())
Definition rom.cc:379
absl::Status WriteShort(int addr, uint16_t value)
Definition rom.cc:673
bool is_loaded() const
Definition rom.h:155
absl::StatusOr< uint32_t > ReadLong(int offset) const
Definition rom.cc:578
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 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 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 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 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.
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::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.
void AddHexField(const std::string &key, uint64_t value, int width=2)
Add a hex-formatted field.
Loads and queries the hack manifest JSON for yaze-ASM integration.
const DungeonStreamLayout * GetDungeonStreamLayout(DungeonStreamType stream) const
Get an explicitly declared dungeon stream layout.
absl::Status LoadFromFile(const std::string &filepath)
Load manifest from a JSON file path.
std::vector< WriteConflict > AnalyzePcWriteRanges(const std::vector< std::pair< uint32_t, uint32_t > > &pc_ranges) const
Analyze a set of PC-offset ranges for write conflicts.
absl::Status Allow(uint32_t start, uint32_t end, std::string_view label)
Definition write_fence.h:32
static int DetermineObjectType(uint8_t b1, uint8_t b3)
bool ArePotItemsLoaded() const
Definition room.h:924
uint8_t GetCollisionTile(int x, int y) const
Definition room.h:531
absl::Status RemoveObject(size_t index)
Definition room.cc:2530
const std::vector< Door > & GetDoors() const
Definition room.h:373
void MarkSpritesDirty()
Definition room.h:279
absl::Status SaveObjects(const DungeonStreamLayout *layout=nullptr)
Definition room.cc:2152
std::vector< uint8_t > EncodeObjects() const
Definition room.cc:1927
const std::vector< zelda3::Sprite > & GetSprites() const
Definition room.h:276
absl::Status SaveSprites(const DungeonStreamLayout *layout=nullptr)
Definition room.cc:2352
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:405
void LoadPotItems()
Definition room.cc:4235
void SetCollisionTile(int x, int y, uint8_t tile)
Definition room.h:537
void LoadSprites()
Definition room.cc:2629
absl::Status AddObject(const RoomObject &object)
Definition room.cc:2516
bool has_custom_collision() const
Definition room.h:523
const std::vector< PotItem > & GetPotItems() const
Definition room.h:389
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
absl::StatusOr< std::vector< uint32_t > > ValidateDungeonPaletteWholeRomDiff(const std::vector< uint8_t > &before, const Rom &after, uint32_t color_pc)
absl::StatusOr< uint32_t > ResolveRoomHeaderPcStrict(const Rom &rom, int room_id)
absl::Status PreflightObjectRemoval(const Rom &source_rom, int room_id, int object_vector_index, const zelda3::Room &pending_room, const ObjectSaveManifestContext *manifest_context)
absl::StatusOr< DungeonPaletteSelection > ResolveDungeonPaletteSelection(const Rom &rom, int room_id)
absl::Status ValidateObjectSaveManifestConflicts(const Rom &source_rom, int room_id, const zelda3::Room &pending_room, const ObjectSaveManifestContext &context)
absl::StatusOr< int > GetOptionalInt(const resources::ArgumentParser &parser, const char *name, int default_value)
absl::StatusOr< std::string > GetRequiredString(const resources::ArgumentParser &parser, const char *name)
absl::StatusOr< int > GetRequiredHex(const resources::ArgumentParser &parser, const char *name)
absl::StatusOr< std::optional< ObjectSaveManifestContext > > LoadObjectSaveManifestContext(const resources::ArgumentParser &parser)
uint32_t DungeonPaletteColorPc(const DungeonPaletteRoomMapping &mapping, int color_index)
bool ContainsRoomId(const std::vector< uint32_t > &room_ids, int room_id)
absl::Status ValidateSnes15BitColor(int color, const char *argument_name)
absl::StatusOr< DungeonPaletteRoomMapping > ResolveDungeonPaletteRoomMapping(const Rom &rom, int room_id)
absl::Status ValidateDungeonPaletteManifestRange(const core::HackManifest &manifest, uint32_t color_pc)
void AddDungeonPaletteScope(resources::OutputFormatter &formatter, const DungeonPaletteSelection &selection)
absl::StatusOr< int > GetRequiredInt(const resources::ArgumentParser &parser, const char *name)
absl::StatusOr< PotItemStreamView > ResolveRuntimePotItemStream(Rom *rom, int room_id)
absl::Status PreflightObjectSave(const Rom &source_rom, int room_id, const zelda3::RoomObject &object, const zelda3::Room &pending_room, const ObjectSaveManifestContext *manifest_context)
absl::StatusOr< uint16_t > ReadDungeonPaletteColor(const Rom &rom, const DungeonPaletteRoomMapping &mapping, int color_index)
absl::Status VerifyDungeonPaletteExternalReadback(const Rom &saved_rom, int room_id, int expected_palette_set, int expected_palette_index, int color_index, uint16_t expected_color)
absl::StatusOr< PotItemManifestContext > LoadPotItemManifestContext(const resources::ArgumentParser &parser)
absl::Status MutateAndSaveRomWithBackup(Rom *rom, resources::OutputFormatter &formatter, Mutation &&mutation)
absl::StatusOr< DoorTypeTarget > ResolveDoorTypeTarget(Rom *rom, int room_id, int x, int y, const ObjectSaveManifestContext &manifest_context)
absl::Status SaveRomWithBackup(Rom *rom, resources::OutputFormatter &formatter)
absl::Status ValidateDoorType(int type, const char *argument_name)
bool IsValidRomRange(const Rom &rom, uint64_t start, uint64_t length)
absl::StatusOr< PotItemTarget > ResolvePotItemTarget(Rom *rom, int room_id, int index, const PotItemManifestContext &manifest_context)
std::string AddressOwnershipToString(AddressOwnership ownership)
absl::StatusOr< zelda3::DungeonStreamLayout > ToDungeonStreamAllocatorLayout(DungeonStreamType stream_type, const DungeonStreamLayout &manifest_layout)
constexpr int DungeonsMainPalettesMax
constexpr int kDungeonMainPalettes
bool ParseHexString(absl::string_view str, uint64_t *out)
Definition hex.cc:133
constexpr uint32_t kPalettesetIdsAddress
Definition game_data.h:44
constexpr int kDoorPointers
Room LoadRoomHeaderFromRom(Rom *rom, int room_id)
Definition room.cc:673
constexpr uint32_t kNumPalettesets
Definition game_data.h:33
constexpr int kDungeonPaletteBytes
Definition game_data.h:46
uint8_t CanonicalRoomObjectSize(int object_id, uint8_t requested_size)
constexpr int kRoomItemsPointers
Room LoadRoomFromRom(Rom *rom, int room_id)
Definition room.cc:648
constexpr DoorType DoorTypeFromRaw(uint8_t raw_type)
Convert raw type byte to DoorType enum.
Definition door_types.h:345
constexpr uint32_t kDungeonPalettePointerTable
Definition game_data.h:45
bool UsesRoomObjectStream(const RoomObject &object)
absl::StatusOr< DungeonStreamInventory > InventoryDungeonStreams(const Rom &rom, const DungeonStreamLayout &requested_layout)
std::string GetObjectName(int object_id)
constexpr std::string_view GetDoorTypeName(DoorType type)
Get human-readable name for door type.
Definition door_types.h:110
constexpr int kNumberOfRooms
constexpr int kRoomHeaderPointer
constexpr int kRoomHeaderPointerBank
absl::Status SaveAllCollision(Rom *rom, absl::Span< Room > rooms)
Definition room.cc:3557
const char * ResolveSpriteName(uint16_t id)
Definition sprite.cc:287
constexpr int kRoomObjectPointer
uint32_t PcToSnes(uint32_t addr)
Definition snes.h:17
uint32_t SnesToPc(uint32_t addr) noexcept
Definition snes.h:8
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
bool load_resource_labels
Definition rom.h:43
Explicit pointer and storage layout for one dungeon stream.
DungeonWriteStrategy strategy
A conflict detected when yaze wants to write to an ASM-owned address.
AddressOwnership ownership
std::vector< DungeonStreamAliasGroup > aliases
std::vector< DungeonStreamIssue > issues
std::vector< DungeonStreamOverlap > overlaps
std::vector< DungeonStreamRecord > streams
std::vector< DungeonStreamPcRange > allocation_ranges
uint16_t position
Definition room.h:131
Represents a door in a dungeon room.
Definition room.h:299
DoorType type
Door type (determines appearance/behavior)
Definition room.h:301
static Door FromRomBytes(uint8_t b1, uint8_t b2)
Definition room.h:357
DoorDirection direction
Which wall the door is on.
Definition room.h:302
uint8_t position
Encoded position (5-bit, 0-31)
Definition room.h:300
std::pair< int, int > GetTileCoords() const
Get tile coordinates for this door.
Definition room.h:308