yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
minecart_commands.cc
Go to the documentation of this file.
2
3#include <array>
4#include <cstdint>
5#include <set>
6#include <string>
7#include <unordered_map>
8#include <unordered_set>
9#include <vector>
10
11#include "absl/strings/ascii.h"
12#include "absl/strings/str_format.h"
13#include "absl/strings/str_join.h"
14#include "absl/strings/str_split.h"
15#include "cli/util/hex_util.h"
16#include "rom/rom.h"
17#include "util/macro.h"
19#include "zelda3/dungeon/room.h"
23
24namespace yaze {
25namespace cli {
26namespace handlers {
27
29
30namespace {
31
32constexpr int kCollisionWidth = 64;
33constexpr int kCollisionHeight = 64;
34
35constexpr std::array<uint8_t, 11> kDefaultTrackTiles = {
36 0xB0, 0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xBB, 0xBC, 0xBD, 0xBE};
37constexpr std::array<uint8_t, 4> kDefaultStopTiles = {0xB7, 0xB8, 0xB9, 0xBA};
38constexpr std::array<uint8_t, 4> kDefaultSwitchTiles = {0xD0, 0xD1, 0xD2, 0xD3};
39
40std::unordered_map<uint8_t, bool> MakeTileSet(
41 const std::vector<uint8_t>& tiles) {
42 std::unordered_map<uint8_t, bool> result;
43 for (uint8_t t : tiles) {
44 result[t] = true;
45 }
46 return result;
47}
48
49std::vector<uint8_t> ToVector(const std::array<uint8_t, 11>& a) {
50 return std::vector<uint8_t>(a.begin(), a.end());
51}
52std::vector<uint8_t> ToVector(const std::array<uint8_t, 4>& a) {
53 return std::vector<uint8_t>(a.begin(), a.end());
54}
55
56absl::StatusOr<int> ParseOptionalHexArg(const resources::ArgumentParser& parser,
57 const std::string& name,
58 int default_value) {
59 auto s = parser.GetString(name);
60 if (!s.has_value()) {
61 return default_value;
62 }
63 int v = 0;
64 if (!ParseHexString(s.value(), &v)) {
65 return absl::InvalidArgumentError(
66 absl::StrFormat("Invalid %s format. Must be hex (e.g., 0x31).", name));
67 }
68 return v;
69}
70
71absl::StatusOr<std::vector<int>> ParseRooms(
72 const resources::ArgumentParser& parser) {
73 std::vector<int> rooms;
74
75 if (parser.HasFlag("all")) {
76 rooms.reserve(320);
77 for (int i = 0; i < 320; ++i) {
78 rooms.push_back(i);
79 }
80 return rooms;
81 }
82
83 auto room_opt = parser.GetString("room");
84 if (room_opt.has_value()) {
85 int room = 0;
86 if (!ParseHexString(room_opt.value(), &room)) {
87 return absl::InvalidArgumentError("Invalid room ID format. Must be hex.");
88 }
89 rooms.push_back(room);
90 return rooms;
91 }
92
93 auto rooms_opt = parser.GetString("rooms");
94 if (!rooms_opt.has_value()) {
95 return absl::InvalidArgumentError(
96 "Missing required args. Use --room, --rooms, or --all.");
97 }
98
99 for (absl::string_view token :
100 absl::StrSplit(rooms_opt.value(), ',', absl::SkipEmpty())) {
101 std::string t = std::string(absl::StripAsciiWhitespace(token));
102 int room = 0;
103 if (!ParseHexString(t, &room)) {
104 return absl::InvalidArgumentError(
105 absl::StrFormat("Invalid room in --rooms list: %s", t));
106 }
107 rooms.push_back(room);
108 }
109
110 return rooms;
111}
112
114 int sprite_id = 0;
115 int x = 0;
116 int y = 0;
117 int subtype = 0;
118 int layer = 0;
119 int tile_x = 0;
120 int tile_y = 0;
121 bool on_stop_tile = false;
122};
123
125 int room_id = 0;
126 bool has_custom_collision_data = false;
127 int track_collision_tiles = 0;
128 int stop_tiles = 0;
129 int switch_tiles = 0;
131 std::vector<MinecartSpriteAudit> minecart_sprites;
132 std::vector<std::string> issues;
133};
134
135RoomMinecartAudit AuditRoom(Rom* rom, int room_id, int track_object_id,
136 int minecart_sprite_id,
137 bool include_track_objects_without_collision) {
138 RoomMinecartAudit audit;
139 audit.room_id = room_id;
140
141 // Load room header, objects, sprites.
142 zelda3::Room room = zelda3::LoadRoomHeaderFromRom(rom, room_id);
143 room.LoadObjects();
144 room.LoadSprites();
145
146 for (const auto& obj : room.GetTileObjects()) {
147 if (static_cast<int>(obj.id_) != track_object_id) {
148 continue;
149 }
150 int subtype = obj.size_ & 0x1F;
151 audit.track_object_subtypes.insert(subtype);
152 }
153
154 // Collision audit.
155 std::unordered_map<uint8_t, bool> track_tiles =
157 std::unordered_map<uint8_t, bool> stop_tiles =
159 std::unordered_map<uint8_t, bool> switch_tiles =
161
162 std::unordered_set<int> stop_positions;
163 auto map_or = zelda3::LoadCustomCollisionMap(rom, room_id);
164 if (map_or.ok() && map_or.value().has_data) {
165 audit.has_custom_collision_data = true;
166 const auto& map = map_or.value().tiles;
167 for (int y = 0; y < kCollisionHeight; ++y) {
168 for (int x = 0; x < kCollisionWidth; ++x) {
169 uint8_t tile = map[static_cast<size_t>(y * kCollisionWidth + x)];
170 if (track_tiles[tile]) {
171 ++audit.track_collision_tiles;
172 }
173 if (stop_tiles[tile]) {
174 ++audit.stop_tiles;
175 stop_positions.insert(y * kCollisionWidth + x);
176 }
177 if (switch_tiles[tile]) {
178 ++audit.switch_tiles;
179 }
180 }
181 }
182 }
183
184 // Sprite audit.
185 for (const auto& sprite : room.GetSprites()) {
186 if (static_cast<int>(sprite.id()) != minecart_sprite_id) {
187 continue;
188 }
190 spr.sprite_id = sprite.id();
191 spr.x = sprite.x();
192 spr.y = sprite.y();
193 spr.subtype = sprite.subtype();
194 spr.layer = sprite.layer();
195 spr.tile_x = spr.x * 2;
196 spr.tile_y = spr.y * 2;
197 if (spr.tile_x >= 0 && spr.tile_x < kCollisionWidth && spr.tile_y >= 0 &&
198 spr.tile_y < kCollisionHeight) {
199 spr.on_stop_tile =
200 stop_positions.find(spr.tile_y * kCollisionWidth + spr.tile_x) !=
201 stop_positions.end();
202 }
203 audit.minecart_sprites.push_back(spr);
204 }
205
206 // Issues (heuristics).
207 const bool has_track_collision =
208 (audit.track_collision_tiles + audit.stop_tiles + audit.switch_tiles) > 0;
209 const bool has_track_objects = !audit.track_object_subtypes.empty();
210 const bool has_minecart_sprites = !audit.minecart_sprites.empty();
211 const bool track_objects_signal =
212 has_track_objects && (include_track_objects_without_collision ||
213 has_track_collision || has_minecart_sprites);
214 bool any_on_stop = false;
215 for (const auto& spr : audit.minecart_sprites) {
216 if (spr.on_stop_tile) {
217 any_on_stop = true;
218 break;
219 }
220 }
221
222 if ((track_objects_signal || has_minecart_sprites) &&
224 audit.issues.push_back(
225 "Room uses minecart objects/sprites but has no custom collision data.");
226 }
227 if (has_minecart_sprites && !has_track_collision) {
228 audit.issues.push_back(
229 "Minecart sprite present but room has no minecart collision tiles.");
230 }
231 if (track_objects_signal && !has_track_collision) {
232 audit.issues.push_back(
233 "Track objects present but room has no minecart collision tiles.");
234 }
235 if (has_track_collision && !has_track_objects) {
236 audit.issues.push_back(
237 "Minecart collision tiles present but no track objects (0x31) found.");
238 }
239 if (has_minecart_sprites && audit.stop_tiles > 0 && !any_on_stop) {
240 audit.issues.push_back(
241 "Minecart sprite present but none placed on a stop tile (B7-BA).");
242 }
243 for (const auto& spr : audit.minecart_sprites) {
244 if (track_objects_signal && !audit.track_object_subtypes.empty() &&
245 audit.track_object_subtypes.find(spr.subtype) ==
246 audit.track_object_subtypes.end()) {
247 audit.issues.push_back(
248 absl::StrFormat("Minecart sprite subtype %d is not referenced by any "
249 "track objects in "
250 "this room.",
251 spr.subtype));
252 }
253 }
254 if (has_track_collision && audit.stop_tiles == 0) {
255 audit.issues.push_back(
256 "Minecart collision tiles present but no stop tiles.");
257 }
258
259 return audit;
260}
261
262} // namespace
263
265 const resources::ArgumentParser& parser) {
266 if (parser.HasFlag("all")) {
267 return absl::OkStatus();
268 }
269 if (parser.GetString("room").has_value()) {
270 return absl::OkStatus();
271 }
272 if (parser.GetString("rooms").has_value()) {
273 return absl::OkStatus();
274 }
275 return absl::InvalidArgumentError(
276 "Missing required args. Use --room, --rooms, or --all.");
277}
278
280 Rom* rom, const resources::ArgumentParser& parser,
281 resources::OutputFormatter& formatter) {
282 ASSIGN_OR_RETURN(auto rooms, ParseRooms(parser));
283
284 ASSIGN_OR_RETURN(int track_object_id,
285 ParseOptionalHexArg(parser, "track-object-id", 0x31));
286 ASSIGN_OR_RETURN(int minecart_sprite_id,
287 ParseOptionalHexArg(parser, "minecart-sprite-id", 0xA3));
288
289 const bool only_issues = parser.HasFlag("only-issues");
290 const bool only_matches = parser.HasFlag("only-matches");
291 const bool include_track_objects = parser.HasFlag("include-track-objects");
292
293 formatter.BeginObject("Dungeon Minecart Audit");
294 formatter.AddField("total_rooms_requested", static_cast<int>(rooms.size()));
295 formatter.AddHexField("track_object_id", track_object_id, 2);
296 formatter.AddHexField("minecart_sprite_id", minecart_sprite_id, 2);
297
298 int rooms_emitted = 0;
299 int rooms_with_issues = 0;
300
301 formatter.BeginArray("rooms");
302 for (int room_id : rooms) {
303 RoomMinecartAudit audit =
304 AuditRoom(rom, room_id, track_object_id, minecart_sprite_id,
305 include_track_objects);
306
307 const bool has_track_collision =
308 (audit.track_collision_tiles + audit.stop_tiles + audit.switch_tiles) >
309 0;
310 const bool has_track_objects = !audit.track_object_subtypes.empty();
311 const bool has_minecart_sprites = !audit.minecart_sprites.empty();
312
313 if (!audit.issues.empty()) {
314 ++rooms_with_issues;
315 }
316 if (only_issues && audit.issues.empty()) {
317 continue;
318 }
319 if (only_matches && !has_track_collision &&
320 !(include_track_objects && has_track_objects) &&
321 !has_minecart_sprites) {
322 continue;
323 }
324
325 formatter.BeginObject();
326 formatter.AddField("room_id", audit.room_id);
327 formatter.AddHexField("room_id_hex", audit.room_id, 2);
328 formatter.AddField("has_custom_collision_data",
329 audit.has_custom_collision_data);
330 formatter.AddField("track_collision_tiles", audit.track_collision_tiles);
331 formatter.AddField("stop_tiles", audit.stop_tiles);
332 formatter.AddField("switch_tiles", audit.switch_tiles);
333
334 formatter.BeginArray("track_object_subtypes");
335 for (int subtype : audit.track_object_subtypes) {
336 formatter.AddArrayItem(absl::StrFormat("%d", subtype));
337 }
338 formatter.EndArray();
339
340 formatter.BeginArray("minecart_sprites");
341 for (const auto& spr : audit.minecart_sprites) {
342 formatter.BeginObject();
343 formatter.AddHexField("sprite_id", spr.sprite_id, 2);
344 formatter.AddField("x", spr.x);
345 formatter.AddField("y", spr.y);
346 formatter.AddField("subtype", spr.subtype);
347 formatter.AddField("layer", spr.layer);
348 formatter.AddField("tile_x", spr.tile_x);
349 formatter.AddField("tile_y", spr.tile_y);
350 formatter.AddField("on_stop_tile", spr.on_stop_tile);
351 formatter.EndObject();
352 }
353 formatter.EndArray();
354
355 formatter.BeginArray("issues");
356 for (const auto& issue : audit.issues) {
357 formatter.AddArrayItem(issue);
358 }
359 formatter.EndArray();
360
361 formatter.EndObject();
362 ++rooms_emitted;
363 }
364 formatter.EndArray();
365
366 formatter.AddField("rooms_emitted", rooms_emitted);
367 formatter.AddField("rooms_with_issues", rooms_with_issues);
368 formatter.AddField("status", "success");
369 formatter.EndObject();
370 return absl::OkStatus();
371}
372
373namespace {
374
375bool IsTrackTile(uint8_t v) {
376 return (v >= 0xB0 && v <= 0xBE) || (v >= 0xD0 && v <= 0xD3);
377}
378
379std::string TrackTileTypeName(uint8_t v) {
380 using T = zelda3::TrackTileType;
381 switch (static_cast<T>(v)) {
382 case T::HorizStraight:
383 return "HorizStraight";
384 case T::VertStraight:
385 return "VertStraight";
386 case T::CornerTL:
387 return "CornerTL";
388 case T::CornerBL:
389 return "CornerBL";
390 case T::CornerTR:
391 return "CornerTR";
392 case T::CornerBR:
393 return "CornerBR";
394 case T::Intersection:
395 return "Intersection";
396 case T::StopNorth:
397 return "StopNorth";
398 case T::StopSouth:
399 return "StopSouth";
400 case T::StopWest:
401 return "StopWest";
402 case T::StopEast:
403 return "StopEast";
404 case T::TJuncNorth:
405 return "TJuncNorth";
406 case T::TJuncSouth:
407 return "TJuncSouth";
408 case T::TJuncEast:
409 return "TJuncEast";
410 case T::TJuncWest:
411 return "TJuncWest";
412 case T::SwitchTL:
413 return "SwitchTL";
414 case T::SwitchBL:
415 return "SwitchBL";
416 case T::SwitchTR:
417 return "SwitchTR";
418 case T::SwitchBR:
419 return "SwitchBR";
420 default:
421 return absl::StrFormat("unknown(0x%02X)", v);
422 }
423}
424
425char TrackTileChar(uint8_t v) {
426 using T = zelda3::TrackTileType;
427 switch (static_cast<T>(v)) {
428 case T::HorizStraight:
429 return '-';
430 case T::VertStraight:
431 return '|';
432 case T::CornerTL:
433 return '/';
434 case T::CornerBL:
435 return '\\';
436 case T::CornerTR:
437 return '\\';
438 case T::CornerBR:
439 return '/';
440 case T::Intersection:
441 return '+';
442 case T::StopNorth:
443 return 'N';
444 case T::StopSouth:
445 return 's';
446 case T::StopWest:
447 return 'W';
448 case T::StopEast:
449 return 'E';
450 case T::TJuncNorth:
451 case T::TJuncSouth:
452 case T::TJuncEast:
453 case T::TJuncWest:
454 return 'T';
455 case T::SwitchTL:
456 case T::SwitchBL:
457 case T::SwitchTR:
458 case T::SwitchBR:
459 return 'X';
460 default:
461 return '?';
462 }
463}
464
465std::string TrackTileCategory(uint8_t v) {
466 if (v >= 0xB0 && v <= 0xB6)
467 return "track";
468 if (v >= 0xB7 && v <= 0xBA)
469 return "stop";
470 if (v >= 0xBB && v <= 0xBE)
471 return "junction";
472 if (v >= 0xD0 && v <= 0xD3)
473 return "switch";
474 return "unknown";
475}
476
477} // namespace
478
480 Rom* rom, const resources::ArgumentParser& parser,
481 resources::OutputFormatter& formatter) {
482 auto room_str = parser.GetString("room");
483 if (!room_str.has_value()) {
484 return absl::InvalidArgumentError("Missing required argument --room");
485 }
486 int room_id = 0;
487 if (!ParseHexString(room_str.value(), &room_id)) {
488 return absl::InvalidArgumentError("Invalid room ID format. Must be hex.");
489 }
490
491 auto map_or = zelda3::LoadCustomCollisionMap(rom, room_id);
492 if (!map_or.ok()) {
493 return map_or.status();
494 }
495 const auto& cmap = map_or.value();
496
497 formatter.BeginObject("Dungeon Minecart Map");
498 formatter.AddField("room_id", room_id);
499 formatter.AddHexField("room_id_hex", room_id, 2);
500 formatter.AddField("has_custom_collision_data", cmap.has_data);
501
502 if (!cmap.has_data) {
503 formatter.AddField("tile_count", 0);
504 formatter.EndObject();
505 return absl::OkStatus();
506 }
507
508 // Collect track tiles and compute bounding box.
509 struct TrackTile {
510 int x, y;
511 uint8_t value;
512 };
513 std::vector<TrackTile> tiles;
514 int min_x = 64, max_x = -1, min_y = 64, max_y = -1;
515
516 for (int y = 0; y < kCollisionHeight; ++y) {
517 for (int x = 0; x < kCollisionWidth; ++x) {
518 uint8_t v = cmap.tiles[static_cast<size_t>(y * kCollisionWidth + x)];
519 if (IsTrackTile(v)) {
520 tiles.push_back({x, y, v});
521 min_x = std::min(min_x, x);
522 max_x = std::max(max_x, x);
523 min_y = std::min(min_y, y);
524 max_y = std::max(max_y, y);
525 }
526 }
527 }
528
529 formatter.AddField("tile_count", static_cast<int>(tiles.size()));
530
531 if (tiles.empty()) {
532 formatter.EndObject();
533 return absl::OkStatus();
534 }
535
536 // Bounding box.
537 formatter.BeginObject("bounding_box");
538 formatter.AddField("min_x", min_x);
539 formatter.AddField("max_x", max_x);
540 formatter.AddField("min_y", min_y);
541 formatter.AddField("max_y", max_y);
542 formatter.EndObject();
543
544 // Tile list.
545 formatter.BeginArray("tiles");
546 for (const auto& t : tiles) {
547 formatter.BeginObject();
548 formatter.AddField("x", t.x);
549 formatter.AddField("y", t.y);
550 formatter.AddHexField("value", t.value, 2);
551 formatter.AddField("type", TrackTileTypeName(t.value));
552 formatter.AddField("category", TrackTileCategory(t.value));
553 formatter.EndObject();
554 }
555 formatter.EndArray();
556
557 // Bounded ASCII grid: column header (tens, units) then one row per tile_y.
558 // Each cell is one character wide at 1:1 scale. Agents can cross-reference
559 // (x, y) from the tile list against this grid to reason about adjacency.
560 {
561 std::unordered_map<int, uint8_t> tile_map;
562 for (const auto& t : tiles) {
563 tile_map[t.y * kCollisionWidth + t.x] = t.value;
564 }
565
566 const int pad = 4; // "NNN " row label width
567 std::string tens_hdr(pad, ' '), units_hdr(pad, ' ');
568 for (int x = min_x; x <= max_x; ++x) {
569 tens_hdr += (x % 10 == 0) ? static_cast<char>('0' + (x / 10) % 10) : ' ';
570 units_hdr += static_cast<char>('0' + x % 10);
571 }
572
573 std::vector<std::string> grid_lines;
574 grid_lines.push_back(tens_hdr);
575 grid_lines.push_back(units_hdr);
576
577 for (int y = min_y; y <= max_y; ++y) {
578 std::string row = absl::StrFormat("%3d ", y);
579 for (int x = min_x; x <= max_x; ++x) {
580 auto it = tile_map.find(y * kCollisionWidth + x);
581 row += (it != tile_map.end()) ? TrackTileChar(it->second) : ' ';
582 }
583 grid_lines.push_back(row);
584 }
585
586 formatter.BeginArray("ascii_grid");
587 for (const auto& line : grid_lines) {
588 formatter.AddArrayItem(line);
589 }
590 formatter.EndArray();
591 }
592
593 formatter.AddField("status", "success");
594 formatter.EndObject();
595 return absl::OkStatus();
596}
597
598} // namespace handlers
599} // namespace cli
600} // namespace yaze
The Rom class is used to load, save, and modify Rom data. This is a generic SNES ROM container and do...
Definition rom.h:28
absl::Status Execute(Rom *rom, const resources::ArgumentParser &parser, resources::OutputFormatter &formatter) override
Execute the command business logic.
absl::Status ValidateArgs(const resources::ArgumentParser &parser) override
Validate command arguments.
absl::Status 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.
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.
const std::vector< zelda3::Sprite > & GetSprites() const
Definition room.h:214
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:314
void LoadObjects()
Definition room.cc:1292
void LoadSprites()
Definition room.cc:1980
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
absl::StatusOr< std::vector< int > > ParseRooms(const resources::ArgumentParser &parser)
absl::StatusOr< int > ParseOptionalHexArg(const resources::ArgumentParser &parser, const std::string &name, int default_value)
RoomMinecartAudit AuditRoom(Rom *rom, int room_id, int track_object_id, int minecart_sprite_id, bool include_track_objects_without_collision)
std::vector< uint8_t > ToVector(const std::array< uint8_t, 11 > &a)
std::unordered_map< uint8_t, bool > MakeTileSet(const std::vector< uint8_t > &tiles)
bool ParseHexString(absl::string_view str, int *out)
Definition hex_util.h:17
Room LoadRoomHeaderFromRom(Rom *rom, int room_id)
Definition room.cc:274
absl::StatusOr< CustomCollisionMap > LoadCustomCollisionMap(Rom *rom, int room_id)