yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_canvas_connected_view.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <array>
5#include <cmath>
6#include <cstdio>
7#include <functional>
8#include <limits>
9#include <map>
10#include <optional>
11#include <queue>
12#include <set>
13#include <string>
14#include <tuple>
15#include <utility>
16#include <vector>
17
18#include "absl/strings/str_format.h"
21
22namespace yaze::editor {
23
24namespace {
25
43
44std::pair<int, int> ConnectedDoorDelta(zelda3::DoorDirection dir) {
45 switch (dir) {
47 return {0, -1};
49 return {0, 1};
51 return {-1, 0};
53 return {1, 0};
54 }
55 return {0, 0};
56}
57
59 const std::map<std::pair<int, int>, int>& occupied_slots, int col,
60 int row) {
61 return occupied_slots.contains({col, row});
62}
63
64std::pair<int, int> FindConnectedDoorPlacement(
65 const std::map<std::pair<int, int>, int>& occupied_slots, int source_col,
66 int source_row, zelda3::DoorDirection dir) {
67 const auto [dx, dy] = ConnectedDoorDelta(dir);
68 int best_score = std::numeric_limits<int>::max();
69 std::pair<int, int> best = {source_col + dx, source_row + dy};
70
71 for (int radius = 1; radius <= 24; ++radius) {
72 for (int row = source_row - radius; row <= source_row + radius; ++row) {
73 for (int col = source_col - radius; col <= source_col + radius; ++col) {
74 if (IsConnectedSlotOccupied(occupied_slots, col, row)) {
75 continue;
76 }
77
78 const int rel_col = col - source_col;
79 const int rel_row = row - source_row;
80 const int forward = (rel_col * dx) + (rel_row * dy);
81 if (forward <= 0) {
82 continue;
83 }
84
85 const int lateral = std::abs((rel_col * dy) - (rel_row * dx));
86 const int distance_from_ideal =
87 std::abs(rel_col - dx) + std::abs(rel_row - dy);
88 const int overshoot = std::max(0, forward - 1);
89 const int score =
90 (distance_from_ideal * 16) + (lateral * 20) + (overshoot * 6);
91 if (score < best_score) {
92 best_score = score;
93 best = {col, row};
94 }
95 }
96 }
97 if (best_score != std::numeric_limits<int>::max()) {
98 return best;
99 }
100 }
101
102 return best;
103}
104
106 const std::map<std::pair<int, int>, int>& occupied_slots, int source_col,
107 int source_row) {
108 int best_score = std::numeric_limits<int>::max();
109 std::pair<int, int> best = {source_col + 1, source_row + 1};
110
111 for (int radius = 1; radius <= 24; ++radius) {
112 for (int row = source_row - radius; row <= source_row + radius; ++row) {
113 for (int col = source_col - radius; col <= source_col + radius; ++col) {
114 if (IsConnectedSlotOccupied(occupied_slots, col, row)) {
115 continue;
116 }
117
118 const int rel_col = col - source_col;
119 const int rel_row = row - source_row;
120 const int distance = std::abs(rel_col) + std::abs(rel_row);
121 if (distance == 0) {
122 continue;
123 }
124
125 const bool diagonal = rel_col != 0 && rel_row != 0;
126 const bool axis_aligned = rel_col == 0 || rel_row == 0;
127 const int distance_bias = std::abs(distance - 2);
128 const int score = (distance_bias * 10) +
129 (diagonal ? 0
130 : axis_aligned ? 8
131 : 4) +
132 ((std::abs(rel_col) + std::abs(rel_row)) > 3 ? 4 : 0);
133 if (score < best_score) {
134 best_score = score;
135 best = {col, row};
136 }
137 }
138 }
139 if (best_score != std::numeric_limits<int>::max()) {
140 return best;
141 }
142 }
143
144 return best;
145}
146
147// Dedup key for the connected-mode graph.
148//
149// Door / Holewarp links are undirected — emitting from either side produces
150// the same logical edge — so we order the room pair (minmax) and treat both
151// emissions as one. There can only be one of either type per room pair.
152//
153// Staircase links carry per-instance provenance (slot_index + object_id) and
154// are *directed*: a stair object lives in exactly one room and points to
155// another. Two stair objects in the same source room targeting the same
156// destination, or two reciprocal stairs (A->B at slot 0 + B->A at slot 1),
157// are distinct edges that must each be visible in the matrix. Including
158// from_room/slot_index/object_id in the key preserves them.
159std::tuple<int, int, DungeonConnectedLinkType, int, int16_t>
162 return std::make_tuple(link.from_room_id, link.to_room_id, link.type,
163 link.slot_index, link.object_id);
164 }
165 const auto ordered_rooms = std::minmax(link.from_room_id, link.to_room_id);
166 return std::make_tuple(ordered_rooms.first, ordered_rooms.second, link.type,
167 -1, static_cast<int16_t>(-1));
168}
169
170} // namespace
171
173 int room_id, const zelda3::Room& room,
174 const std::function<bool(int, zelda3::DoorDirection)>&
175 has_reciprocal_door) {
177
178 const auto& doors = room.GetDoors();
179 for (size_t door_index = 0; door_index < doors.size(); ++door_index) {
180 const auto& door = doors[door_index];
181 if (!zelda3::IsRoomConnectionDoorType(door.type)) {
182 continue;
183 }
184
185 const int neighbor = NeighborRoomId(room_id, door.direction);
186 if (neighbor < 0) {
187 continue;
188 }
189 if (has_reciprocal_door &&
190 !has_reciprocal_door(neighbor, OppositeDir(door.direction))) {
191 continue;
192 }
193
195 link.from_room_id = room_id;
196 link.to_room_id = neighbor;
198 link.direction = door.direction;
199 link.door_index = static_cast<int>(door_index);
200 link.door_type = door.type;
201 result.links.push_back(link);
202 }
203
204 // Provisional navigation only: placement order is not a verified runtime
205 // slot mapping. Vanilla writers use family-specific counters ($0438,
206 // $047E/$0482, $04A2/$04A4, $043A, $0480/$0484, $04A6/$04A8) into $06B0.
207 // Collision generation assigns indices later; $0462 & 3 selects the header.
208 // Do not use these estimates to mutate or clear room metadata.
209 std::array<bool, 4> slot_consumed{false, false, false, false};
210 std::array<int16_t, 4> slot_object_id{
211 static_cast<int16_t>(-1), static_cast<int16_t>(-1),
212 static_cast<int16_t>(-1), static_cast<int16_t>(-1)};
213 int next_slot = 0;
214 for (const auto& object : room.GetTileObjects()) {
215 if (!IsHeaderBackedInterroomStaircaseObject(object.id_)) {
216 continue;
217 }
218 if (next_slot >= 4) {
220 extra.from_room_id = room_id;
222 extra.object_id = object.id_;
223 result.staircase_issues.push_back(extra);
224 continue;
225 }
226 slot_consumed[next_slot] = true;
227 slot_object_id[next_slot] = object.id_;
228 ++next_slot;
229 }
230
231 for (int slot = 0; slot < 4; ++slot) {
232 // Room 000 is a valid destination. The runtime writes the header byte to
233 // $A0 and keeps $A1, so $119 slot $1D leads to $11D; zero is not a
234 // sentinel for a consumed slot.
235 const int stair_room = room.staircase_destination_room(slot);
236 const bool header_valid =
237 stair_room >= 0 && stair_room < zelda3::kNumberOfRooms;
238 if (slot_consumed[slot]) {
239 if (header_valid) {
241 link.from_room_id = room_id;
242 link.to_room_id = stair_room;
245 link.slot_index = slot;
246 link.object_id = slot_object_id[slot];
247 result.links.push_back(link);
248 } else {
250 issue.from_room_id = room_id;
252 issue.slot_index = slot;
253 issue.header_room_id = stair_room;
254 issue.object_id = slot_object_id[slot];
255 result.staircase_issues.push_back(issue);
256 }
257 } else if (header_valid && room.staircase_room(slot) != 0) {
259 issue.from_room_id = room_id;
261 issue.slot_index = slot;
262 issue.header_room_id = stair_room;
263 result.staircase_issues.push_back(issue);
264 }
265 }
266
267 const int holewarp_room = room.holewarp_destination_room();
268 if (room.holewarp() != 0 && holewarp_room < zelda3::kNumberOfRooms) {
270 link.from_room_id = room_id;
271 link.to_room_id = holewarp_room;
274 result.links.push_back(link);
275 }
276
277 return result;
278}
279
280std::vector<DungeonConnectedRoomLink> CollectDungeonConnectedRoomLinks(
281 int room_id, const zelda3::Room& room,
282 const std::function<bool(int, zelda3::DoorDirection)>&
283 has_reciprocal_door) {
285 has_reciprocal_door)
286 .links;
287}
288
289namespace {
290
292 switch (dir) {
294 return "North";
296 return "South";
298 return "West";
300 return "East";
301 }
302 return "?";
303}
304
305} // namespace
306
308 const DungeonConnectedRoomLink& link) {
309 switch (link.type) {
311 return absl::StrFormat("Door (%s, type 0x%02X) -> [%03X]",
312 DoorDirectionLabel(link.direction),
313 static_cast<unsigned>(link.door_type),
314 static_cast<unsigned>(link.to_room_id));
316 std::string object_str =
317 (link.object_id >= 0)
318 ? absl::StrFormat("0x%03X", static_cast<unsigned>(link.object_id))
319 : std::string("(unknown)");
320 const int slot_index = link.slot_index >= 0 ? link.slot_index : -1;
321 if (slot_index < 0) {
322 return absl::StrFormat("Estimated staircase obj %s -> [%03X]",
323 object_str,
324 static_cast<unsigned>(link.to_room_id));
325 }
326 return absl::StrFormat("Estimated staircase slot %d obj %s -> [%03X]",
327 slot_index, object_str,
328 static_cast<unsigned>(link.to_room_id));
329 }
331 return absl::StrFormat("Holewarp -> [%03X]",
332 static_cast<unsigned>(link.to_room_id));
333 }
334 return absl::StrFormat("Link -> [%03X]",
335 static_cast<unsigned>(link.to_room_id));
336}
337
339 const DungeonStaircaseIssue& issue) {
340 switch (issue.kind) {
342 return absl::StrFormat(
343 "Possibly unused staircase slot %d -> [%03X] (no placed "
344 "interroom-stair "
345 "object consumes this slot)",
346 issue.slot_index, static_cast<unsigned>(issue.header_room_id));
348 const std::string object_str =
349 (issue.object_id >= 0)
350 ? absl::StrFormat("0x%03X",
351 static_cast<unsigned>(issue.object_id))
352 : std::string("(unknown)");
353 const std::string header_str = absl::StrFormat(
354 "0x%03X (out of range)", static_cast<unsigned>(issue.header_room_id));
355 return absl::StrFormat(
356 "Estimated missing staircase destination at slot %d (placed object "
357 "%s, "
358 "header value %s)",
359 issue.slot_index, object_str, header_str);
360 }
362 const std::string object_str =
363 (issue.object_id >= 0)
364 ? absl::StrFormat("0x%03X",
365 static_cast<unsigned>(issue.object_id))
366 : std::string("(unknown)");
367 return absl::StrFormat(
368 "Additional staircase object %s beyond the estimated 4 slots "
369 "(runtime mapping unverified)",
370 object_str);
371 }
372 }
373 return std::string("Unknown staircase issue");
374}
375
377 int room_id) {
378 if (!rooms_ || !rom_ || !rom_->is_loaded() || room_id < 0 ||
379 room_id >= zelda3::kNumberOfRooms) {
380 return nullptr;
381 }
382
383 auto* room_ptr = rooms_->TryEnsureRoom(room_id);
384 if (!room_ptr) {
385 return nullptr;
386 }
387 auto& room = *room_ptr;
388 room.SetRom(rom_);
390 if (!room.IsLoaded()) {
391 room = zelda3::LoadRoomFromRom(rom_, room_id);
393 }
394 return &room;
395}
396
398 int room_id, zelda3::DoorDirection dir) {
400 if (!room) {
401 return false;
402 }
403
404 for (const auto& door : room->GetDoors()) {
405 if (door.direction == dir && zelda3::IsRoomConnectionDoorType(door.type)) {
406 return true;
407 }
408 }
409 return false;
410}
411
415 if (start_room_id < 0 || start_room_id >= zelda3::kNumberOfRooms) {
416 return graph;
417 }
418
419 zelda3::Room* start_room = EnsureRoomLoadedForConnectedView(start_room_id);
420 if (!start_room) {
421 return graph;
422 }
423
424 // Project-aware scoping: when the project registry maps the start room to
425 // a dungeon entry, restrict BFS to that dungeon's rooms by default.
426 // Cross-blockset / cross-dungeon resolved links are still surfaced via
427 // out_of_scope_links so the diagnostic remains visible without polluting
428 // the visual matrix with rooms from other dungeons.
429 std::set<int> scoped_room_ids;
430 std::map<int, const core::DungeonRoom*> scoped_rooms_by_id;
431 if (const auto* dungeon =
433 graph.dungeon_scope_active = true;
434 for (const auto& dungeon_room : dungeon->rooms) {
435 if (dungeon_room.id < 0 || dungeon_room.id >= zelda3::kNumberOfRooms) {
436 continue;
437 }
438 scoped_room_ids.insert(dungeon_room.id);
439 scoped_rooms_by_id[dungeon_room.id] = &dungeon_room;
440 graph.room_floor_labels[static_cast<size_t>(dungeon_room.id)] =
441 dungeon_room.floor;
442 if (!dungeon_room.floor.empty() &&
443 std::find(graph.floor_order.begin(), graph.floor_order.end(),
444 dungeon_room.floor) == graph.floor_order.end()) {
445 graph.floor_order.push_back(dungeon_room.floor);
446 }
447 }
448 }
449
450 std::map<std::pair<int, int>, int> occupied_slots;
451 std::queue<int> to_visit;
452 std::set<std::tuple<int, int, DungeonConnectedLinkType, int, int16_t>>
453 seen_links;
454
455 auto track_room_bounds = [&](int room_id) {
456 const auto& placement = graph.room_positions[static_cast<size_t>(room_id)];
457 graph.min_col = std::min(graph.min_col, placement.col);
458 graph.max_col = std::max(graph.max_col, placement.col);
459 graph.min_row = std::min(graph.min_row, placement.row);
460 graph.max_row = std::max(graph.max_row, placement.row);
461 };
462
463 auto registry_placement_for =
464 [&](int room_id) -> std::optional<std::pair<int, int>> {
465 const auto it = scoped_rooms_by_id.find(room_id);
466 if (it == scoped_rooms_by_id.end() || !it->second->has_grid_position) {
467 return std::nullopt;
468 }
469 return std::make_pair(it->second->grid_col, it->second->grid_row);
470 };
471
472 auto place_room = [&](int room_id, std::pair<int, int> desired_placement,
473 bool connected_to_start) {
474 if (room_id < 0 || room_id >= zelda3::kNumberOfRooms) {
475 return;
476 }
477 const size_t index = static_cast<size_t>(room_id);
478 if (graph.room_mask[index]) {
479 if (connected_to_start &&
480 !graph.room_positions[index].connected_to_start) {
481 graph.room_positions[index].connected_to_start = true;
482 graph.unlinked_room_count = std::max(0, graph.unlinked_room_count - 1);
483 }
484 return;
485 }
486
487 std::pair<int, int> placement = desired_placement;
488 const auto occupied = occupied_slots.find(placement);
489 if (occupied != occupied_slots.end() && occupied->second != room_id) {
490 placement = FindConnectedTransportPlacement(
491 occupied_slots, desired_placement.first, desired_placement.second);
492 }
493
494 graph.room_mask[index] = true;
495 graph.room_positions[index] = {placement.first, placement.second, true,
496 connected_to_start};
497 occupied_slots[placement] = room_id;
498 ++graph.room_count;
499 if (!connected_to_start) {
500 ++graph.unlinked_room_count;
501 }
502 if (graph.room_count == 1) {
503 graph.min_col = graph.max_col = placement.first;
504 graph.min_row = graph.max_row = placement.second;
505 } else {
506 track_room_bounds(room_id);
507 }
508 };
509
510 auto in_dungeon_scope = [&](int candidate_room_id) {
511 if (!graph.dungeon_scope_active) {
512 return true;
513 }
514 return scoped_room_ids.find(candidate_room_id) != scoped_room_ids.end();
515 };
516
517 place_room(
518 start_room_id,
519 registry_placement_for(start_room_id).value_or(std::make_pair(0, 0)),
520 true);
521 to_visit.push(start_room_id);
522
523 while (!to_visit.empty()) {
524 const int room_id = to_visit.front();
525 to_visit.pop();
526
528 if (!room) {
529 continue;
530 }
531
532 const auto outgoing_diagnostics =
534 room_id, *room,
535 [this](int neighbor_room_id, zelda3::DoorDirection dir) {
536 return RoomHasNonExitDoorInDirection(neighbor_room_id, dir);
537 });
538 for (const auto& issue : outgoing_diagnostics.staircase_issues) {
539 graph.staircase_issues.push_back(issue);
540 }
541 const auto& outgoing_links = outgoing_diagnostics.links;
542
543 for (const auto& link : outgoing_links) {
544 if (link.to_room_id < 0 || link.to_room_id >= zelda3::kNumberOfRooms) {
545 continue;
546 }
547
548 // Out-of-scope link (target room is not in the current dungeon group):
549 // record as a diagnostic, do not recurse, do not add the target to the
550 // visual graph. We still de-dup against `seen_links` so a reciprocal
551 // edge from the other side won't double-list it later.
552 if (!in_dungeon_scope(link.to_room_id)) {
553 if (seen_links.insert(MakeConnectedLinkKey(link)).second) {
554 graph.out_of_scope_links.push_back(link);
555 }
556 continue;
557 }
558
559 if (!EnsureRoomLoadedForConnectedView(link.to_room_id)) {
560 continue;
561 }
562
563 if (seen_links.insert(MakeConnectedLinkKey(link)).second) {
564 graph.links.push_back(link);
565 }
566
567 if (!graph.room_mask[static_cast<size_t>(link.to_room_id)]) {
568 const auto& source_placement =
569 graph.room_positions[static_cast<size_t>(room_id)];
570 const std::pair<int, int> placement =
571 registry_placement_for(link.to_room_id)
572 .value_or((link.type == DungeonConnectedLinkType::Door)
573 ? FindConnectedDoorPlacement(
574 occupied_slots, source_placement.col,
575 source_placement.row, link.direction)
576 : FindConnectedTransportPlacement(
577 occupied_slots, source_placement.col,
578 source_placement.row));
579 place_room(link.to_room_id, placement, true);
580 to_visit.push(link.to_room_id);
581 }
582 }
583 }
584
585 // In project-scoped mode, connected view should show the whole dungeon group,
586 // not only the currently reachable component. Unlinked rooms are drawn in the
587 // same canvas with floor badges so bad headers/stairs are visible and fixable
588 // instead of hiding the rest of the floor.
589 if (graph.dungeon_scope_active) {
590 const auto start_placement =
591 graph.room_positions[static_cast<size_t>(start_room_id)];
592 for (int room_id : scoped_room_ids) {
593 if (graph.room_mask[static_cast<size_t>(room_id)]) {
594 continue;
595 }
596 // Project registry entries can name rooms that have never been opened.
597 // Materialize their ROM data before placing them in the preview graph;
598 // otherwise the renderer sees a default Room with ID zero.
599 if (!EnsureRoomLoadedForConnectedView(room_id)) {
600 continue;
601 }
602 const std::pair<int, int> placement =
603 registry_placement_for(room_id).value_or(
604 FindConnectedTransportPlacement(
605 occupied_slots, start_placement.col, start_placement.row));
606 place_room(room_id, placement, false);
607 }
608 }
609
610 return graph;
611}
612
614 int center_room_id) {
615 // Retain a non-mutating guard for legacy callers until the runtime mapping
616 // is implemented. Neither cached diagnostics nor a fresh estimate proves
617 // that a destination is unused.
618 (void)center_room_id;
620 "Automatic staircase repair is unavailable: slot mapping is unverified. "
621 "Review destinations in the room inspector.";
623 return 0;
624}
625
626} // namespace yaze::editor
bool is_loaded() const
Definition rom.h:187
bool RoomHasNonExitDoorInDirection(int room_id, zelda3::DoorDirection dir)
int ApplyConnectedStaircaseIssueAutoFixes(int center_room_id)
zelda3::Room * EnsureRoomLoadedForConnectedView(int room_id)
ConnectedRoomGraphData BuildConnectedRoomGraph(int start_room_id)
const project::YazeProject * project_
zelda3::Room * TryEnsureRoom(int room_id)
int GetRoutineIdForObject(int16_t object_id) const
static DrawRoutineRegistry & Get()
uint8_t holewarp() const
Definition room.h:1028
int holewarp_destination_room() const
Definition room.h:1008
int staircase_destination_room(int index) const
Definition room.h:1004
uint8_t staircase_room(int index) const
Definition room.h:999
const std::vector< Door > & GetDoors() const
Definition room.h:413
bool IsLoaded() const
Definition room.h:970
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:445
void SetRom(Rom *rom)
Definition room.h:1085
void SetGameData(GameData *data)
Definition room.h:1088
bool IsConnectedSlotOccupied(const std::map< std::pair< int, int >, int > &occupied_slots, int col, int row)
std::pair< int, int > FindConnectedDoorPlacement(const std::map< std::pair< int, int >, int > &occupied_slots, int source_col, int source_row, zelda3::DoorDirection dir)
std::tuple< int, int, DungeonConnectedLinkType, int, int16_t > MakeConnectedLinkKey(const DungeonConnectedRoomLink &link)
std::pair< int, int > FindConnectedTransportPlacement(const std::map< std::pair< int, int >, int > &occupied_slots, int source_col, int source_row)
const core::DungeonEntry * FindDungeonForRoom(const project::YazeProject *project, int room_id, size_t *dungeon_index=nullptr)
Editors are the view controllers for the application.
std::string FormatDungeonConnectedLinkDescription(const DungeonConnectedRoomLink &link)
int NeighborRoomId(int room_id, zelda3::DoorDirection dir)
DungeonConnectedRoomLinkDiagnostics CollectDungeonConnectedRoomLinkDiagnostics(int room_id, const zelda3::Room &room, const std::function< bool(int, zelda3::DoorDirection)> &has_reciprocal_door)
zelda3::DoorDirection OppositeDir(zelda3::DoorDirection dir)
std::vector< DungeonConnectedRoomLink > CollectDungeonConnectedRoomLinks(int room_id, const zelda3::Room &room, const std::function< bool(int, zelda3::DoorDirection)> &has_reciprocal_door)
std::string FormatDungeonStaircaseIssueDescription(const DungeonStaircaseIssue &issue)
constexpr bool IsRoomConnectionDoorType(DoorType type)
Return true when a door can represent an adjacent-room connection.
Definition door_types.h:333
Room LoadRoomFromRom(Rom *rom, int room_id)
Definition room.cc:655
constexpr int kNumberOfRooms
DoorDirection
Door direction on room walls.
Definition door_types.h:18
@ South
Bottom wall (horizontal door, 4x3 tiles)
@ North
Top wall (horizontal door, 4x3 tiles)
@ East
Right wall (vertical door, 3x4 tiles)
@ West
Left wall (vertical door, 3x4 tiles)
std::array< RoomPlacement, zelda3::kNumberOfRooms > room_positions
std::array< std::string, zelda3::kNumberOfRooms > room_floor_labels
std::vector< DungeonConnectedRoomLink > links
std::vector< DungeonStaircaseIssue > staircase_issues