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