yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
dungeon_object_selector.cc
Go to the documentation of this file.
1// Related header
3#include "absl/strings/str_format.h"
4#include "util/i18n/tr.h"
5
6// C system headers
7#include <cstring>
8#include <filesystem>
9
10// C++ standard library headers
11#include <algorithm>
12#include <array>
13#include <cctype>
14#include <iterator>
15
16// Third-party library headers
17#include "imgui/imgui.h"
18
19// Project headers
25#include "app/gui/core/icons.h"
29#include "core/features.h"
30#include "rom/rom.h"
31#include "zelda3/dungeon/custom_object.h" // For CustomObjectManager
40#include "zelda3/dungeon/room.h"
41#include "zelda3/dungeon/room_object.h" // For GetObjectName()
42
43namespace yaze::editor {
44
45namespace {
46
48constexpr const char* kObjectGridDensityLabels[] = {"Compact", "Medium",
49 "Large"};
50
51float GetObjectGridItemSize(int density) {
52 switch (density) {
53 case 0:
54 return 54.0f;
55 case 2:
56 return 76.0f;
57 case 1:
58 default:
59 return 60.0f;
60 }
61}
62
63ImU32 ThemeColor(const ImVec4& color) {
64 return ImGui::ColorConvertFloat4ToU32(color);
65}
66
67ImVec4 WithAlpha(ImVec4 color, float alpha) {
68 color.w = alpha;
69 return color;
70}
71
72void DrawFallbackPreviewTile(ImDrawList* draw_list, ImVec2 top_left,
73 ImVec2 size, const ImVec4& accent_color,
74 const char* label) {
75 const auto& theme = AgentUI::GetTheme();
76 draw_list->AddRectFilled(
77 top_left, ImVec2(top_left.x + size.x, top_left.y + size.y),
78 ThemeColor(WithAlpha(theme.panel_bg_darker, 0.72f)), 2.0f);
79 draw_list->AddRectFilled(top_left,
80 ImVec2(top_left.x + 2.0f, top_left.y + size.y),
81 ThemeColor(WithAlpha(accent_color, 0.72f)), 2.0f);
82
83 ImVec2 label_size = ImGui::CalcTextSize(label);
84 if (label_size.x > size.x - 4.0f || label_size.y > size.y - 4.0f) {
85 return;
86 }
87 ImVec2 label_pos(top_left.x + (size.x - label_size.x) / 2,
88 top_left.y + (size.y - label_size.y) / 2);
89 draw_list->AddText(label_pos,
90 ThemeColor(WithAlpha(theme.text_primary, 0.82f)), label);
91}
92
95 switch (origin) {
97 return "Oracle default filename";
99 return "Configured filename";
101 return "Configured slot unmapped";
102 }
103 return "Unknown mapping";
104}
105
106} // namespace
107
109 float available_width, float preferred_item_size, float item_spacing,
110 float min_item_size) {
111 const float safe_spacing = std::max(item_spacing, 0.0f);
112 const float usable_width = std::max(available_width, 1.0f);
113 const float safe_min_item_size =
114 std::min(std::max(min_item_size, 1.0f), usable_width);
115 const float clamped_preferred_item_size =
116 std::clamp(preferred_item_size, safe_min_item_size, usable_width);
117
119 layout.columns = std::max(
120 1, static_cast<int>((usable_width + safe_spacing) /
121 (clamped_preferred_item_size + safe_spacing)));
122 layout.item_size = clamped_preferred_item_size;
123 const float total_spacing = safe_spacing * (layout.columns - 1);
124 const float occupied_width =
125 layout.item_size * layout.columns + total_spacing;
126 layout.leading_inset = std::max((usable_width - occupied_width) * 0.5f, 0.0f);
127 return layout;
128}
129
131 float source_height,
132 float box_width,
133 float box_height) {
135 if (source_width <= 0.0f || source_height <= 0.0f || box_width <= 0.0f ||
136 box_height <= 0.0f) {
137 return fit;
138 }
139
140 const float scale =
141 std::min(box_width / source_width, box_height / source_height);
142 fit.valid = true;
143 fit.width = source_width * scale;
144 fit.height = source_height * scale;
145 fit.x = (box_width - fit.width) * 0.5f;
146 fit.y = (box_height - fit.height) * 0.5f;
147 return fit;
148}
149
150bool MatchesDungeonObjectStreamFilter(int object_id, int selected_filter) {
151 switch (selected_filter) {
152 case 1:
153 return object_id >= 0x000 && object_id <= 0x0F7;
154 case 2:
155 return object_id >= 0x100 && object_id <= 0x13F;
156 case 3:
157 return object_id >= 0xF80 && object_id <= 0xFFF;
158 case 0:
159 default:
160 return true;
161 }
162}
163
164bool IsDungeonCustomObjectRuntimeSlot(int object_id, int subtype) {
165 return subtype >= 0 &&
167 object_id);
168}
169
170bool IsMinecartGraphicsRuntimeSlot(int object_id, int subtype) {
171 return object_id == 0x31 &&
172 zelda3::IsMinecartTrackGraphicsSubtype(object_id, subtype);
173}
174
175std::string GetDungeonCustomObjectSlotName(int object_id, int subtype) {
176 static constexpr std::array<const char*, 16> kObject31Names = {
177 "Pit track horizontal", "Pit track vertical",
178 "Pit track corner top-left", "Pit track corner top-right",
179 "Pit track corner bottom-left", "Pit track corner bottom-right",
180 "Floor track vertical", "Floor track horizontal",
181 "Floor track corner top-left", "Floor track corner top-right",
182 "Floor track corner bottom-left", "Floor track corner bottom-right",
183 "Floor track any direction", "Sword House wall object",
184 "Pit track any direction", "Small statue",
185 };
186 static constexpr std::array<const char*, 3> kObject32Names = {
187 "Ice furnace",
188 "Firewood",
189 "Ice chair",
190 };
191 static constexpr std::array<const char*, 2> kObject54Names = {
192 "Kydreeok body",
193 "Manhandla body",
194 };
195 if (object_id == 0x31 && subtype >= 0 &&
196 subtype < static_cast<int>(kObject31Names.size())) {
197 return kObject31Names[subtype];
198 }
199 if (object_id == 0x32 && subtype >= 0 &&
200 subtype < static_cast<int>(kObject32Names.size())) {
201 return kObject32Names[subtype];
202 }
203 if (object_id == 0x54 && subtype >= 0 &&
204 subtype < static_cast<int>(kObject54Names.size())) {
205 return kObject54Names[subtype];
206 }
207 return "Unknown custom runtime slot";
208}
209
213
215 const auto category = zelda3::ObjectCategories::GetObjectCategory(object_id);
216 return category.ok() && *category == "Chests";
217}
218
220 const auto& theme = AgentUI::GetTheme();
221 const auto category = zelda3::ObjectCategories::GetObjectCategory(object_id);
222 if (!category.ok())
223 return ImGui::GetColorU32(theme.dungeon_object_default);
224 if (*category == "Walls")
225 return ImGui::GetColorU32(theme.dungeon_object_wall);
226 if (*category == "Floors")
227 return ImGui::GetColorU32(theme.dungeon_object_floor);
228 if (*category == "Decorations")
229 return ImGui::GetColorU32(theme.dungeon_object_decoration);
230 if (*category == "Chests")
231 return ImGui::GetColorU32(theme.item_color);
232 if (*category == "Stairs")
233 return ImGui::GetColorU32(theme.selection_primary);
234 if (*category == "Doors")
235 return ImGui::GetColorU32(theme.transport_color);
236 if (*category == "Interactive")
237 return ImGui::GetColorU32(theme.status_success);
238 return ImGui::GetColorU32(theme.music_zone_color);
239}
240
242 const auto category = zelda3::ObjectCategories::GetObjectCategory(object_id);
243 if (!category.ok())
244 return "?";
245 if (*category == "Walls")
246 return "|";
247 if (*category == "Floors")
248 return "_";
249 if (*category == "Decorations")
250 return "~";
251 if (*category == "Chests")
252 return "C";
253 if (*category == "Stairs")
254 return "^";
255 if (*category == "Doors")
256 return "D";
257 if (*category == "Interactive")
258 return "o";
259 return "S";
260}
261
262void DungeonObjectSelector::SelectObject(int obj_id, int subtype) {
263 const int runtime_count =
265 if (runtime_count > 0 && subtype < 0 &&
266 core::FeatureFlags::get().kEnableCustomObjects) {
267 // In custom-enabled projects these IDs are dispatch families, not one
268 // subtype-free object. Selection must come from Custom Assets so the exact
269 // runtime slot and its decoded source asset are known.
270 return;
271 }
272 if (subtype >= 0) {
273 if ((runtime_count > 0 &&
274 !IsDungeonCustomObjectRuntimeSlot(obj_id, subtype)) ||
275 (runtime_count == 0 && subtype >= kPersistedCustomSubtypeSlots)) {
276 return;
277 }
278 if (runtime_count > 0) {
279 if (!core::FeatureFlags::get().kEnableCustomObjects) {
280 return;
281 }
283 if (!GetCustomObjectAssetStatus(obj_id, subtype).ok()) {
284 return;
285 }
286 }
287 }
288
289 selected_object_id_ = obj_id;
290
291 // Create and update preview object
292 uint8_t size = zelda3::DefaultRoomObjectSizeForPlacement(obj_id);
293 if (subtype >= 0) {
294 // Oracle custom objects use the explicitly selected size as a subtype.
295 size =
296 zelda3::CanonicalRoomObjectSize(obj_id, static_cast<uint8_t>(subtype));
297 }
298 preview_object_ = zelda3::RoomObject(obj_id, 0, 0, size, 0);
300 object_loaded_ = true;
305 .ok();
306
307 // Notify callback
310 }
311}
312
315 if (!core::FeatureFlags::get().kEnableCustomObjects) {
316 ImGui::TextDisabled("This project has no custom rail assets.");
317 return false;
318 }
319 const auto layout = ResolveDungeonObjectSelectorGridLayout(
320 ImGui::GetContentRegionAvail().x, 64.0f, ImGui::GetStyle().ItemSpacing.x);
321 bool chosen = false;
322 ImGui::PushID("MinecartRailPalette");
323 // Keep the complete catalog visible, with the same shape order in both
324 // families. These are artwork choices; behavior is edited separately.
325 static constexpr std::array<std::array<int, 7>, 2> kFamilies = {
326 {{7, 6, 8, 9, 10, 11, 12}, {0, 1, 2, 3, 4, 5, 14}}};
327 for (size_t family = 0; family < kFamilies.size(); ++family) {
328 ImGui::TextDisabled("%s",
329 family == 0 ? "Floor-style rails" : "Pit-style rails");
330 int column = 0;
331 for (int subtype : kFamilies[family]) {
332 if (column)
333 ImGui::SameLine();
334 const auto status = GetCustomObjectAssetStatus(0x31, subtype);
335 const auto name = GetDungeonCustomObjectSlotName(0x31, subtype);
336 const auto label = absl::StrFormat("##Rail%d", subtype);
337 ImGui::BeginDisabled(!status.ok());
338 if (ImGui::Selectable(label.c_str(),
340 preview_object_.size_ == subtype,
341 0, ImVec2(layout.item_size, layout.item_size))) {
342 SelectObject(0x31, subtype);
343 chosen = true;
344 }
345 gui::AutoRegisterLastItem("selectable",
346 absl::StrFormat("Rail%d", subtype));
347 ImGui::EndDisabled();
348 const auto top = ImGui::GetItemRectMin();
349 auto obj = MakePreviewObject(0x31);
350 obj.size_ = subtype;
351 const bool rendered =
352 status.ok() && ImGui::IsItemVisible() &&
353 DrawObjectPreview(obj, ImVec2(top.x + 3, top.y + 3),
354 ImVec2(layout.item_size - 6, layout.item_size - 6));
355 if (!rendered) {
356 ImGui::GetWindowDrawList()->AddText(
357 top, ImGui::GetColorU32(ImGuiCol_TextDisabled),
358 status.ok() ? "Preview..." : "Missing");
359 }
360 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
361 ImGui::BeginTooltip();
362 ImGui::TextUnformatted(name.c_str());
363 ImGui::TextUnformatted(status.ok()
364 ? "Click, then place on the room canvas."
365 : std::string(status.message()).c_str());
366 if (status.ok() && family == 0) {
367 ImGui::TextUnformatted(
368 "Uses fixed floor tiles; check the room's graphics and palette.");
369 }
370 ImGui::EndTooltip();
371 }
372 column = (column + 1) % layout.columns;
373 }
374 }
375 ImGui::PopID();
376 return chosen;
377}
378
379void DungeonObjectSelector::DrawObjectAssetBrowser(float minimum_grid_height) {
382 ImGui::TextWrapped("%s", custom_object_action_error_.c_str());
383 }
384 const auto& theme = AgentUI::GetTheme();
385
386 // Object ranges supported by the room-object stream codec.
387 struct ObjectRange {
388 int start;
389 int end;
390 const char* label;
391 };
392 static const ObjectRange ranges[] = {
393 {0x00, 0xF7, "Type 1"},
394 {0x100, 0x13F, "Type 2"},
395 {0xF80, 0xFFF, "Type 3"},
396 };
397
399 auto& obj_manager = zelda3::CustomObjectManager::Get();
400 int custom_count = 0;
401 for (const int object_id : zelda3::CustomObjectManager::RuntimeObjectIds()) {
402 custom_count += std::min(obj_manager.GetSubtypeCount(object_id),
403 kPersistedCustomSubtypeSlots);
404 }
405
406 const std::string custom_mode_label =
407 absl::StrFormat("Custom Assets (%d)", custom_count);
408 if (ImGui::BeginTable(
409 "##ObjectBrowserMode", 2,
410 ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX)) {
411 ImGui::TableNextColumn();
412 if (ImGui::Selectable(tr("Objects"), browser_mode_ == BrowserMode::kObjects,
413 0, ImVec2(ImGui::GetContentRegionAvail().x, 0.0f))) {
415 }
416 ImGui::TableNextColumn();
417 if (ImGui::Selectable(custom_mode_label.c_str(),
419 ImVec2(ImGui::GetContentRegionAvail().x, 0.0f))) {
421 }
422 ImGui::EndTable();
423 }
424
427 return;
428 }
429 ImGui::Spacing();
430
431 const ImGuiStyle& style = ImGui::GetStyle();
432 const float control_spacing = std::max(2.0f, style.ItemSpacing.x * 0.5f);
433 {
434 gui::StyleVarGuard toolbar_spacing_guard(
435 ImGuiStyleVar_ItemSpacing,
436 ImVec2(control_spacing, std::max(2.0f, style.ItemSpacing.y * 0.5f)));
437
438 const float toolbar_width =
439 std::max(ImGui::GetContentRegionAvail().x, 1.0f);
440 ImGui::SetNextItemWidth(toolbar_width);
441 ImGui::InputTextWithHint("##ObjectSearch", "Search by name or hex ID...",
443 sizeof(object_search_buffer_));
444
445 static const char* kFilterLabels[] = {
446 "All categories", "Walls", "Floors", "Chests",
447 "Doorways", "Decor", "Stairs"};
448 static const char* kStreamLabels[] = {"All streams", "Type 1", "Type 2",
449 "Type 3"};
450 constexpr const char* kMoreLabel = "More##ObjectSelectorMore";
451 const float more_width = ImGui::CalcTextSize(kMoreLabel, nullptr, true).x +
452 style.FramePadding.x * 2.0f;
453 const bool stack_filters = toolbar_width < 230.0f;
454 const float filter_row_width =
455 std::max(1.0f, toolbar_width - more_width - control_spacing);
456 const float used_width = ImGui::GetFrameHeight() +
457 ImGui::CalcTextSize("Used here").x +
458 style.ItemInnerSpacing.x;
459 const float category_width =
460 stack_filters
461 ? toolbar_width
462 : std::max(1.0f, filter_row_width - used_width - control_spacing);
463
464 ImGui::SetNextItemWidth(category_width);
465 ImGui::Combo("##ObjectFilterType", &object_type_filter_, kFilterLabels,
466 IM_ARRAYSIZE(kFilterLabels));
467 if (object_type_filter_ == 4 && ImGui::IsItemHovered()) {
468 ImGui::SetTooltip(
469 tr("Doorways are tile objects. Use the Door Editor "
470 "to place or change room doors."));
471 }
472 if (!stack_filters) {
473 ImGui::SameLine(0.0f, control_spacing);
474 }
475 ImGui::Checkbox("Used here", &used_here_only_);
476 ImGui::SameLine(0.0f, control_spacing);
477 if (ImGui::Button(kMoreLabel, ImVec2(more_width, 0.0f))) {
478 ImGui::OpenPopup("##ObjectSelectorOptionsPopup");
479 }
480 if (ImGui::BeginPopup("##ObjectSelectorOptionsPopup")) {
481 const bool has_filters = object_search_buffer_[0] != '\0' ||
482 object_type_filter_ != 0 ||
484 if (ImGui::MenuItem(tr("Clear filters"), nullptr, false, has_filters)) {
485 object_search_buffer_[0] = '\0';
488 used_here_only_ = false;
489 }
490
491 ImGui::SetNextItemWidth(130.0f);
492 ImGui::Combo("Object stream", &object_stream_filter_, kStreamLabels,
493 IM_ARRAYSIZE(kStreamLabels));
494
495 ImGui::SeparatorText(tr("Display"));
496 ImGui::Checkbox(tr("Show thumbnails"), &enable_object_previews_);
497 if (ImGui::IsItemHovered()) {
498 ImGui::SetTooltip(
499 tr("Show rendered object thumbnails in the selector.\n"
500 "Requires a room to be loaded and may cost some performance."));
501 }
502 ImGui::SeparatorText(tr("Card size"));
503 for (int density = 0; density < 3; ++density) {
504 if (ImGui::MenuItem(kObjectGridDensityLabels[density], nullptr,
505 object_grid_density_ == density)) {
506 object_grid_density_ = density;
507 }
508 }
509
510 ImGui::EndPopup();
511 }
512 }
513
514 // The grid is the selector's sole scroll owner. Calculate its geometry only
515 // after entering the child so themed padding and the scrollbar are included.
516 const float child_height = std::max(ImGui::GetContentRegionAvail().y,
517 std::max(minimum_grid_height, 1.0f));
518 if (ImGui::BeginChild("##ObjectGrid", ImVec2(0, child_height), false)) {
519 const float item_spacing = control_spacing;
520 // GetContentRegionAvail() already excludes the child window's scrollbar
521 // and padding. Centering the fixed-density grid balances any remainder so
522 // a resize cannot leave a second, artificial gutter on the right.
523 const auto grid_layout = ResolveDungeonObjectSelectorGridLayout(
524 ImGui::GetContentRegionAvail().x,
525 GetObjectGridItemSize(object_grid_density_), item_spacing);
526 const float item_size = grid_layout.item_size;
527 const int columns = grid_layout.columns;
528 gui::StyleVarGuard grid_spacing_guard(
529 ImGuiStyleVar_ItemSpacing,
530 ImVec2(item_spacing, std::max(item_spacing, style.ItemSpacing.y)));
531
532 // Iterate through all object ranges
533 for (const auto& range : ranges) {
534 if (!MatchesDungeonObjectStreamFilter(range.start,
536 continue;
537 }
538
539 if (object_stream_filter_ == 0) {
540 ImGui::TextDisabled("%s 0x%03X-0x%03X", range.label, range.start,
541 range.end);
542 }
543
544 int current_column = 0;
545
546 for (int obj_id = range.start; obj_id <= range.end; ++obj_id) {
549 0) {
550 // Custom-enabled runtime families are shown only in Custom Assets,
551 // where an exact subtype and validated source asset are required.
552 continue;
553 }
555 continue;
556 }
557 if (!MatchesRoomUsage(obj_id))
558 continue;
560 continue;
561 }
562
563 std::string full_name = zelda3::GetObjectName(obj_id);
564 if (!MatchesObjectSearch(obj_id, full_name)) {
565 continue;
566 }
567
568 if (current_column > 0) {
569 ImGui::SameLine(0.0f, item_spacing);
570 } else {
571 ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
572 grid_layout.leading_inset);
573 }
574
575 ImGui::PushID(obj_id);
576
577 // Create selectable button for object
578 bool is_selected = (selected_object_id_ == obj_id);
579 ImVec2 button_size(item_size, item_size);
580
581 if (ImGui::Selectable("", is_selected, 0, button_size)) {
582 SelectObject(obj_id);
583 }
584 const bool item_visible = ImGui::IsItemVisible();
585 // Routine symbology — one fast registry lookup per visible card only.
586 // Off-screen items are skipped entirely (no work queued until culled).
588 if (item_visible) {
589 sym = zelda3::GetSymbologyForObject(static_cast<int16_t>(obj_id));
590 }
591 ImVec2 button_pos = ImGui::GetItemRectMin();
592 DrawObjectContextMenu(obj_id);
594 static_cast<uint16_t>(obj_id), current_room_id_, 0, 0,
596 ImDrawList* draw_list = ImGui::GetWindowDrawList();
597 const bool show_id = item_size >= 32.0f;
598 const float footer_height =
599 show_id ? std::min(item_size * 0.32f, ImGui::GetFontSize() + 3.0f)
600 : 0.0f;
601 const float card_padding = std::min(3.0f, item_size * 0.08f);
602 const ImVec2 preview_pos(button_pos.x + card_padding,
603 button_pos.y + card_padding);
604 const ImVec2 preview_box(
605 std::max(1.0f, item_size - card_padding * 2.0f),
606 std::max(1.0f, item_size - footer_height - card_padding * 2.0f));
607
608 // Only attempt graphical preview if enabled (performance optimization)
609 bool rendered = false;
610 if (item_visible && enable_object_previews_) {
611 rendered = DrawObjectPreview(MakePreviewObject(obj_id), preview_pos,
612 preview_box);
613 }
614
615 if (item_visible && !rendered) {
616 std::string symbol = GetObjectTypeSymbol(obj_id);
617 DrawFallbackPreviewTile(
618 draw_list, preview_pos, preview_box,
619 ImGui::ColorConvertU32ToFloat4(GetObjectTypeColor(obj_id)),
620 symbol.c_str());
621 }
622
623 const bool item_hovered = ImGui::IsItemHovered();
624 if (item_visible && (is_selected || item_hovered)) {
625 const ImU32 border_color =
626 ImGui::GetColorU32(is_selected ? theme.dungeon_selection_primary
627 : theme.panel_border_color);
628 draw_list->AddRect(
629 button_pos,
630 ImVec2(button_pos.x + item_size, button_pos.y + item_size),
631 border_color, 2.0f, 0, is_selected ? 2.0f : 1.0f);
632 }
633
634 // Compact routine symbology badge — top-right corner overlay, drawn
635 // over both graphical thumbnails and fallback tiles. Only rendered
636 // for visible cards (sym is only populated when item_visible).
637 // Skipped for unmapped objects (badge == "?") to reduce visual noise.
638 if (item_visible && !sym.badge.empty() && sym.badge != "?") {
639 const ImVec2 badge_text_size =
640 ImGui::CalcTextSize(sym.badge.c_str(), nullptr, true);
641 const float badge_pad = 2.0f;
642 const float badge_w = badge_text_size.x + badge_pad * 2.0f;
643 const float badge_h = badge_text_size.y + badge_pad;
644 const ImVec2 badge_tl(button_pos.x + item_size - badge_w,
645 button_pos.y);
646 const ImVec2 badge_br(button_pos.x + item_size,
647 button_pos.y + badge_h);
648 draw_list->AddRectFilled(
649 badge_tl, badge_br,
650 ImGui::GetColorU32(WithAlpha(theme.panel_bg_darker, 0.82f)),
651 1.5f);
652 // BothBG routines use the wall-accent color; others use secondary
653 // text so the badge never competes with the thumbnail content.
654 const ImVec4& badge_color = sym.dual_layer
655 ? theme.dungeon_object_wall
656 : theme.text_secondary_gray;
657 draw_list->AddText(
658 ImVec2(badge_tl.x + badge_pad, badge_tl.y + badge_pad * 0.5f),
659 ImGui::GetColorU32(badge_color), sym.badge.c_str());
660 }
661
662 if (item_visible && show_id) {
663 const ImVec2 card_bottom(button_pos.x + item_size,
664 button_pos.y + item_size);
665 const float footer_y = card_bottom.y - footer_height;
666 draw_list->AddRectFilled(
667 ImVec2(button_pos.x, footer_y), card_bottom,
668 ImGui::GetColorU32(WithAlpha(theme.panel_bg_darker, 0.88f)),
669 2.0f);
670 std::string id_text = absl::StrFormat("%03X", obj_id);
671 ImVec2 id_size = ImGui::CalcTextSize(id_text.c_str());
672 ImVec2 id_pos = ImVec2(button_pos.x + (item_size - id_size.x) / 2,
673 footer_y + (footer_height - id_size.y) * 0.5f);
674 draw_list->AddText(id_pos, ImGui::GetColorU32(theme.text_primary),
675 id_text.c_str());
676 }
677
678 // Enhanced tooltip
679 if (item_hovered) {
680 gui::StyleColorGuard tooltip_guard(
681 {{ImGuiCol_PopupBg, theme.panel_bg_color},
682 {ImGuiCol_Border, theme.panel_border_color}});
683
684 if (ImGui::BeginTooltip()) {
685 ImGui::TextColored(theme.selection_primary, tr("Object 0x%03X"),
686 obj_id);
687 ImGui::Text("%s", full_name.c_str());
688 int subtype = zelda3::GetObjectSubtype(obj_id);
689 ImGui::TextColored(theme.text_secondary_gray, tr("Subtype %d"),
690 subtype);
691 ImGui::TextColored(
692 rendered ? theme.status_success : theme.status_warning,
693 tr("Preview: %s"),
694 rendered ? "rendered tile layout"
695 : (enable_object_previews_ ? "fallback symbol"
696 : "thumbnails off"));
697 ImGui::Separator();
698
699 const uint8_t preview_size =
701 const bool can_capture_layout =
702 rom_ && rooms_ && current_room_id_ >= 0 &&
704 const zelda3::Room* layout_room =
705 can_capture_layout ? &(*rooms_)[current_room_id_] : nullptr;
706 const uint32_t layout_key =
707 MakeLayoutCacheKey(obj_id, preview_size, layout_room);
708 if (can_capture_layout &&
709 layout_cache_.find(layout_key) == layout_cache_.end()) {
711 auto& room_ref = *layout_room;
712 auto layout_or = editor.CaptureObjectLayout(
713 obj_id, room_ref, current_palette_group_, preview_size);
714 if (layout_or.ok()) {
715 layout_cache_[layout_key] = layout_or.value();
716 }
717 }
718
719 if (layout_cache_.count(layout_key)) {
720 const auto& layout = layout_cache_[layout_key];
721 ImGui::TextColored(theme.status_success, tr("Tiles: %zu"),
722 layout.cells.size());
723
724 if (can_capture_layout) {
725 auto& room_ref = (*rooms_)[current_room_id_];
727 room_ref.get_gfx_buffer().data());
728 int rid = drawer.GetDrawRoutineId(obj_id);
729 ImGui::TextColored(theme.status_active, tr("Draw Routine: %d"),
730 rid);
731 }
732
733 ImGui::Text(tr("Layout:"));
734 ImDrawList* tooltip_draw_list = ImGui::GetWindowDrawList();
735 ImVec2 grid_start = ImGui::GetCursorScreenPos();
736 float cell_size = 4.0f;
737 for (const auto& cell : layout.cells) {
738 ImVec2 p1(grid_start.x + cell.rel_x * cell_size,
739 grid_start.y + cell.rel_y * cell_size);
740 ImVec2 p2(p1.x + cell_size, p1.y + cell_size);
741 tooltip_draw_list->AddRectFilled(
742 p1, p2, ThemeColor(theme.dungeon_grid_cell_highlight));
743 tooltip_draw_list->AddRect(
744 p1, p2, ThemeColor(theme.panel_border_color));
745 }
746 ImGui::Dummy(ImVec2(layout.bounds_width * cell_size,
747 layout.bounds_height * cell_size));
748 }
749
750 // Routine symbology context — built here (hover-only) so no
751 // strings are allocated for off-screen or non-hovered items.
752 // sym was computed when the card became visible; accessing it
753 // inside the tooltip block is always safe.
754 if (!sym.family.empty()) {
755 ImGui::Separator();
756 // Named specials (Chest, BigKeyLock, etc.) use item_color so
757 // they stand out; directional families use secondary text.
758 const bool is_named_special =
759 (sym.badge == "C" || sym.badge == "K" || sym.badge == "B" ||
760 sym.badge == "P");
761 if (is_named_special) {
762 ImGui::TextColored(theme.item_color, tr("Routine: %s"),
763 sym.family.c_str());
764 } else {
765 ImGui::TextColored(theme.text_secondary_gray, tr("Routine: %s"),
766 sym.family.c_str());
767 }
768 if (sym.dual_layer) {
769 ImGui::TextColored(theme.dungeon_object_wall,
770 tr(" BothBG: writes to BG1 and BG2"));
771 }
772 }
773
774 ImGui::Separator();
775 ImGui::TextColored(theme.text_secondary_gray,
776 tr("Click to select for placement"));
777 ImGui::EndTooltip();
778 }
779 }
780
781 ImGui::PopID();
782
783 current_column = (current_column + 1) % columns;
784 } // end object loop
785 } // end range loop
786 }
787
788 ImGui::EndChild();
789}
790
791void DungeonObjectSelector::DrawObjectContextMenu(int object_id, int subtype) {
792 if (!ImGui::BeginPopupContextItem("##ObjectActions"))
793 return;
794 const bool supported =
795 subtype >= 0
796 ? GetCustomObjectAssetStatus(object_id, subtype).ok()
798 const bool has_context = rooms_ && rooms_->GetIfLoaded(current_room_id_);
799 const bool can_edit =
800 supported && has_context &&
801 (subtype >= 0 ? tile_editor_panel_ != nullptr
802 : static_cast<bool>(edit_object_tiles_callback_));
803 if (ImGui::MenuItem("Edit tiles...", nullptr, false, can_edit)) {
804 const auto status =
805 subtype >= 0 ? OpenExistingCustomObjectEditor(object_id, subtype,
810 status.ok() ? "" : std::string(status.message());
811 }
812 if (!supported && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
813 ImGui::SetTooltip(
814 "Tile editing is available for straight walls, 4x4 corners, "
815 "fixed 2x2 objects and custom assets. This layout is not supported "
816 "yet.");
817 }
818 ImGui::EndPopup();
819}
820
822 if (!used_here_only_)
823 return true;
824 const auto* room = rooms_ ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
825 if (!room)
826 return false;
827 const auto& objects = room->GetTileObjects();
828 return std::any_of(
829 objects.begin(), objects.end(),
830 [obj_id](const auto& object) { return object.id_ == obj_id; });
831}
832
833bool DungeonObjectSelector::MatchesObjectFilter(int obj_id, int filter_type) {
834 constexpr std::array<const char*, 6> kCategories = {
835 "Walls", "Floors", "Chests", "Doors", "Decorations", "Stairs"};
836 if (filter_type < 1 || filter_type > static_cast<int>(kCategories.size())) {
837 return true;
838 }
839 const auto category = zelda3::ObjectCategories::GetObjectCategory(obj_id);
840 return category.ok() && *category == kCategories[filter_type - 1];
841}
842
844 const std::string& name,
845 int subtype) const {
846 if (object_search_buffer_[0] == '\0') {
847 return true;
848 }
849
850 auto to_lower = [](std::string value) {
851 std::transform(value.begin(), value.end(), value.begin(),
852 [](unsigned char c) { return std::tolower(c); });
853 return value;
854 };
855
856 std::string needle = to_lower(object_search_buffer_);
857 std::string name_lower = to_lower(name);
858
859 std::string id_hex = absl::StrFormat("%03X", obj_id);
860 std::string id_lower = to_lower(id_hex);
861 std::string id_pref = "0x" + id_lower;
862
863 if (name_lower.find(needle) != std::string::npos) {
864 return true;
865 }
866 if (id_lower.find(needle) != std::string::npos ||
867 id_pref.find(needle) != std::string::npos) {
868 return true;
869 }
870
871 if (subtype >= 0) {
872 std::string sub_hex = absl::StrFormat("%02X", subtype);
873 std::string sub_lower = to_lower(sub_hex);
874 std::string combined = id_lower + ":" + sub_lower;
875 std::string combined_pref = "0x" + combined;
876 if (combined.find(needle) != std::string::npos ||
877 combined_pref.find(needle) != std::string::npos) {
878 return true;
879 }
880 }
881
882 return false;
883}
884
886 const zelda3::RoomObject& object, int& width, int& height) {
888 width = std::min(w, 256);
889 height = std::min(h, 256);
890}
891
898
900 const bool custom_objects_enabled =
902 const uint64_t generation =
904 const bool custom_feature_changed =
905 custom_objects_enabled != observed_custom_objects_enabled_;
906 if (generation == observed_custom_object_generation_ &&
907 !custom_feature_changed) {
908 return;
909 }
910
911 if (rooms_ != nullptr) {
913 [](int, zelda3::Room& room) { room.MarkObjectsDirty(); });
914 }
917 observed_custom_objects_enabled_ = custom_objects_enabled;
918
919 const int object_id = preview_object_.id_;
920 const int subtype = preview_object_.size_ & 0x1F;
921 const bool fixed_runtime_slot =
922 IsDungeonCustomObjectRuntimeSlot(object_id, subtype);
923 auto& custom_object_manager = zelda3::CustomObjectManager::Get();
924 const bool exact_override_mapped_now =
925 !fixed_runtime_slot &&
926 !custom_object_manager.ResolveFilename(object_id, subtype).empty();
927 if (!object_loaded_ ||
928 (!fixed_runtime_slot && !preview_uses_custom_override_ &&
929 !exact_override_mapped_now)) {
930 return;
931 }
932
933 // A subtype-free vanilla selection for a custom runtime family stores an
934 // ordinary size in the
935 // same bits used by Oracle's custom dispatch. Never reinterpret that queued
936 // placement across either feature-state edge; require a fresh selection.
937 if (fixed_runtime_slot && custom_feature_changed) {
938 object_loaded_ = false;
943 }
944 return;
945 }
946
947 // With the feature stably disabled, these object IDs retain vanilla size
948 // semantics. A custom-asset cache or mapping change is irrelevant to that
949 // queued placement.
950 if (fixed_runtime_slot && !custom_objects_enabled) {
951 return;
952 }
953
954 if (fixed_runtime_slot &&
955 !GetCustomObjectAssetStatus(object_id, subtype).ok()) {
956 object_loaded_ = false;
961 }
962 return;
963 }
964
965 if (!fixed_runtime_slot) {
967 custom_objects_enabled &&
968 custom_object_manager.GetObjectInternal(object_id, subtype).ok();
969 }
970
973 }
974}
975
995
997 int subtype) {
998 if (!IsDungeonCustomObjectRuntimeSlot(object_id, subtype)) {
999 return absl::OutOfRangeError(
1000 "Custom object subtype is outside the runtime dispatch table");
1001 }
1002
1003 const uint32_t key =
1004 (static_cast<uint32_t>(object_id) << 8) | static_cast<uint32_t>(subtype);
1005 if (const auto cached = custom_asset_status_cache_.find(key);
1006 cached != custom_asset_status_cache_.end()) {
1007 return cached->second;
1008 }
1009
1010 auto object_or =
1012 absl::Status status = object_or.ok() ? absl::OkStatus() : object_or.status();
1013 custom_asset_status_cache_.emplace(key, status);
1014 return status;
1015}
1016
1018 zelda3::RoomObject obj(obj_id, 0, 0,
1020 obj.SetRom(rom_);
1021 return obj;
1022}
1023
1030
1032 auto& arena = gfx::Arena::Get();
1033 for (auto& [key, preview] : preview_cache_) {
1034 (void)key;
1035 if (preview != nullptr) {
1036 arena.RetireBitmap(preview->bitmap());
1037 }
1038 }
1039 preview_cache_.clear();
1040 failed_preview_keys_.clear();
1041}
1042
1064
1066 uint8_t preview_size,
1067 const zelda3::Room* room) {
1068 uint8_t room_floor = 0;
1069 if (room != nullptr) {
1070 if (object_id == 0xC4) {
1071 room_floor = room->floor1() & 0x0F;
1072 } else if (object_id == 0xDB) {
1073 room_floor = room->floor2() & 0x0F;
1074 }
1075 }
1076
1077 return (static_cast<uint32_t>(object_id) << 16) |
1078 (static_cast<uint32_t>(preview_size) << 8) | room_floor;
1079}
1080
1082 gfx::BackgroundBuffer** out) {
1083 if (out == nullptr) {
1084 return false;
1085 }
1086 *out = nullptr;
1087 if (!rom_ || !rom_->is_loaded()) {
1088 return false;
1089 }
1091
1092 // Check if room context changed - invalidate cache if so
1093 const zelda3::Room* room =
1094 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
1095 if (room == nullptr) {
1096 return false;
1097 }
1099
1100 // Check if already in cache
1101 // Key: object, subtype, blockset, palette, and both room floor nibbles.
1102 // Room/entrance changes clear the complete cache before this lookup.
1103 const uint8_t preview_size =
1104 zelda3::CanonicalRoomObjectSize(object.id_, object.size());
1105 int subtype = preview_size & 0x1F;
1106 uint64_t cache_key =
1107 (static_cast<uint64_t>(object.id_) << 32) |
1108 (static_cast<uint64_t>(subtype) << 24) |
1109 (static_cast<uint64_t>(cached_preview_blockset_) << 16) |
1110 (static_cast<uint64_t>(cached_preview_palette_) << 8) |
1111 (static_cast<uint64_t>(cached_preview_floor1_ & 0x0F) << 4) |
1112 static_cast<uint64_t>(cached_preview_floor2_ & 0x0F);
1113
1114 auto it = preview_cache_.find(cache_key);
1115 if (it != preview_cache_.end()) {
1116 *out = it->second.get();
1117 return (*out)->bitmap().texture() != nullptr;
1118 }
1119 // Objects whose layout cannot be captured or rendered stay failed until the
1120 // cache is invalidated; without this every visible card rebuilt an
1121 // ObjectDrawer and two 512x512 buffers each frame.
1122 if (failed_preview_keys_.count(cache_key) != 0) {
1123 return false;
1124 }
1125
1126 // Create new preview using ObjectTileEditor
1127 const uint8_t* gfx_data = room->get_gfx_buffer().data();
1128
1130 auto layout_or = editor.CaptureObjectLayout(
1131 object.id_, *room, current_palette_group_, preview_size);
1132 if (!layout_or.ok()) {
1133 failed_preview_keys_.insert(cache_key);
1134 return false;
1135 }
1136 const auto& layout = layout_or.value();
1137
1138 // Create preview buffer large enough for object
1139 int bmp_w = std::max(8, layout.bounds_width * 8);
1140 int bmp_h = std::max(8, layout.bounds_height * 8);
1141 auto preview = std::make_unique<gfx::BackgroundBuffer>(bmp_w, bmp_h);
1142 preview->EnsureBitmapInitialized();
1143
1144 // Render layout to bitmap
1145 auto render_status = editor.RenderLayoutToBitmap(
1146 layout, preview->bitmap(), gfx_data, current_palette_group_);
1147 if (!render_status.ok()) {
1148 gfx::Arena::Get().RetireBitmap(preview->bitmap());
1149 failed_preview_keys_.insert(cache_key);
1150 return false;
1151 }
1152
1153 auto& bitmap = preview->bitmap();
1154 // Texture creation and SDL sync
1155 if (!bitmap.surface()) {
1156 gfx::Arena::Get().RetireBitmap(bitmap);
1157 failed_preview_keys_.insert(cache_key);
1158 return false;
1159 }
1160 SDL_LockSurface(bitmap.surface());
1161 memcpy(bitmap.surface()->pixels, bitmap.mutable_data().data(),
1162 bitmap.mutable_data().size());
1163 SDL_UnlockSurface(bitmap.surface());
1164
1165 // Install the owner before queuing CREATE. The renderer may defer this
1166 // command until DoRender, so the Bitmap address must remain valid even when
1167 // this frame falls back to the symbolic preview.
1168 auto [cache_it, inserted] =
1169 preview_cache_.try_emplace(cache_key, std::move(preview));
1170 if (!inserted) {
1171 if (preview != nullptr) {
1172 gfx::Arena::Get().RetireBitmap(preview->bitmap());
1173 }
1174 *out = cache_it->second.get();
1175 return (*out)->bitmap().texture() != nullptr;
1176 }
1177
1178 *out = cache_it->second.get();
1179 auto& cached_bitmap = (*out)->bitmap();
1181 &cached_bitmap);
1183
1184 // A null texture is an expected deferred state when the Arena has no active
1185 // renderer yet. Keep the cache entry and its queued owner alive; the next
1186 // frame will draw it after DoRender processes CREATE.
1187 return cached_bitmap.texture() != nullptr;
1188}
1189
1191 ImVec2 top_left,
1192 ImVec2 box_size) {
1193 gfx::BackgroundBuffer* preview = nullptr;
1194 if (!GetOrCreatePreview(object, &preview)) {
1195 return false;
1196 }
1197
1198 // Draw the cached preview image
1199 auto& bitmap = preview->bitmap();
1200 if (!bitmap.texture()) {
1201 return false;
1202 }
1203
1205 static_cast<float>(bitmap.width()), static_cast<float>(bitmap.height()),
1206 box_size.x, box_size.y);
1207 if (!fit.valid) {
1208 return false;
1209 }
1210
1211 const ImVec2 image_top_left(top_left.x + fit.x, top_left.y + fit.y);
1212 const ImVec2 image_bottom_right(image_top_left.x + fit.width,
1213 image_top_left.y + fit.height);
1214 ImGui::GetWindowDrawList()->AddImage((ImTextureID)(intptr_t)bitmap.texture(),
1215 image_top_left, image_bottom_right,
1216 ImVec2(0, 0), ImVec2(1, 1),
1217 ImGui::GetColorU32(ImVec4(1, 1, 1, 1)));
1218 return true;
1219}
1220
1222 int16_t object_id, int subtype, int room_id) {
1223 if (tile_editor_panel_ == nullptr) {
1224 return absl::FailedPreconditionError(
1225 "Object Tile Editor panel is unavailable");
1226 }
1227
1228 const absl::Status open_status = tile_editor_panel_->OpenForCustomObject(
1229 object_id, subtype, room_id, rooms_, current_palette_group_);
1230 if (!open_status.ok()) {
1233 }
1234 return open_status;
1235 }
1239 return absl::NotFoundError(
1240 "Object Tile Editor window is not registered in this session");
1241 }
1242 return absl::OkStatus();
1243}
1244
1246 const auto& theme = AgentUI::GetTheme();
1247 auto& obj_manager = zelda3::CustomObjectManager::Get();
1248 const std::string custom_base_path = obj_manager.GetBasePath();
1249
1250 ImGui::TextColored(theme.text_info,
1251 ICON_MD_PRECISION_MANUFACTURING " Custom Assets");
1252 ImGui::PushTextWrapPos(0.0f);
1253 ImGui::TextColored(
1254 theme.text_secondary_gray,
1255 tr("Manage the 21 fixed runtime assets used by Oracle custom objects."));
1256 ImGui::TextDisabled(
1257 "%s", tr("New subtypes require an ASM dispatch-table change; this "
1258 "browser edits or places existing slots only."));
1259 ImGui::PopTextWrapPos();
1260 ImGui::Separator();
1261
1262 if (ImGui::BeginTable(
1263 "##CustomObjectToolbar", 2,
1264 ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_NoPadOuterX)) {
1265 ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 2.0f);
1266 ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed,
1267 180.0f);
1268 ImGui::TableNextRow();
1269
1270 ImGui::TableNextColumn();
1271 if (custom_base_path.empty()) {
1272 ImGui::TextColored(theme.text_warning_yellow, ICON_MD_WARNING
1273 " Custom object folder is not configured.");
1274 } else {
1275 ImGui::TextColored(theme.text_secondary_gray,
1276 ICON_MD_FOLDER " Asset folder");
1277 if (ImGui::IsItemHovered()) {
1278 ImGui::SetTooltip("%s", custom_base_path.c_str());
1279 }
1280 }
1281
1282 ImGui::TableNextColumn();
1283 if (ImGui::Button(ICON_MD_REFRESH " Reload Assets", ImVec2(-1, 0))) {
1284 obj_manager.ReloadAll();
1287 }
1288 if (ImGui::IsItemHovered()) {
1289 ImGui::SetTooltip(
1290 tr("Refresh custom object and external sprite previews from disk. "
1291 "Unsaved room and tile edits are kept."));
1292 }
1293 ImGui::EndTable();
1294 }
1295
1296 if (!core::FeatureFlags::get().kEnableCustomObjects) {
1297 ImGui::TextColored(
1298 theme.text_warning_yellow, ICON_MD_WARNING
1299 " Custom Objects is disabled for this project. Enable the feature "
1300 "before editing or placing runtime assets.");
1301 return;
1302 }
1303
1304 ImGui::TextDisabled("%s", tr("Card size"));
1305 ImGui::SameLine();
1306 ImGui::SetNextItemWidth(-1.0f);
1307 ImGui::Combo("##CustomAssetCardSize", &object_grid_density_,
1308 kObjectGridDensityLabels,
1309 IM_ARRAYSIZE(kObjectGridDensityLabels));
1310
1311 ImGui::PushTextWrapPos(0.0f);
1312 ImGui::TextColored(
1313 theme.text_secondary_gray, ICON_MD_INFO
1314 " Track tile layouts are assets here; routes and collision are managed "
1315 "in Minecart Tracks.");
1316 ImGui::PopTextWrapPos();
1317 ImGui::Spacing();
1318
1319 struct CustomAssetFamily {
1320 int object_id;
1321 const char* label;
1322 const char* description;
1323 };
1324 static constexpr std::array<CustomAssetFamily, 3> kFamilies = {{
1325 {0x31, "Tracks + Props (16)",
1326 "Minecart tile layouts, the Sword House wall object, and a small "
1327 "statue"},
1328 {0x32, "Ice Props (3)", "Furnace, firewood, and chair assets"},
1329 {0x54, "Boss Bodies (2)", "Kydreeok and Manhandla body tilemaps"},
1330 }};
1331 const CustomAssetFamily* selected_family = &kFamilies.front();
1332 for (const auto& family : kFamilies) {
1333 if (custom_asset_family_id_ == family.object_id) {
1334 selected_family = &family;
1335 break;
1336 }
1337 }
1338 ImGui::TextDisabled("%s", tr("Asset family"));
1339 ImGui::SetNextItemWidth(-1.0f);
1340 if (ImGui::BeginCombo("##CustomAssetFamily", selected_family->label)) {
1341 for (const auto& family : kFamilies) {
1342 const bool selected = custom_asset_family_id_ == family.object_id;
1343 if (ImGui::Selectable(family.label, selected)) {
1344 custom_asset_family_id_ = family.object_id;
1346 selected_family = &family;
1348 }
1349 if (selected) {
1350 ImGui::SetItemDefaultFocus();
1351 }
1352 }
1353 ImGui::EndCombo();
1354 }
1355 ImGui::PushTextWrapPos(0.0f);
1356 ImGui::TextDisabled("%s", tr(selected_family->description));
1357 ImGui::PopTextWrapPos();
1358
1359 {
1360 const ImGuiStyle& style = ImGui::GetStyle();
1361 const float item_spacing = std::max(2.0f, style.ItemSpacing.x * 0.5f);
1362 const auto grid_layout = ResolveDungeonObjectSelectorGridLayout(
1363 ImGui::GetContentRegionAvail().x,
1364 GetObjectGridItemSize(object_grid_density_), item_spacing);
1365 const int columns = grid_layout.columns;
1366 const float item_size = grid_layout.item_size;
1367 gui::StyleVarGuard grid_spacing_guard(
1368 ImGuiStyleVar_ItemSpacing,
1369 ImVec2(item_spacing, std::max(item_spacing, style.ItemSpacing.y)));
1370 int custom_col = 0;
1371 for (const int obj_id : zelda3::CustomObjectManager::RuntimeObjectIds()) {
1372 if (obj_id != custom_asset_family_id_) {
1373 continue;
1374 }
1375 const int subtype_count = std::min(obj_manager.GetSubtypeCount(obj_id),
1376 kPersistedCustomSubtypeSlots);
1377 for (int subtype = 0; subtype < subtype_count; ++subtype) {
1378 const std::string subtype_name =
1379 GetDungeonCustomObjectSlotName(obj_id, subtype);
1380
1381 if (custom_col > 0) {
1382 ImGui::SameLine(0.0f, item_spacing);
1383 } else {
1384 ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
1385 grid_layout.leading_inset);
1386 }
1387
1388 ImGui::PushID(obj_id * 1000 + subtype);
1389
1390 const absl::Status asset_status =
1391 GetCustomObjectAssetStatus(obj_id, subtype);
1392 const bool asset_ready = asset_status.ok();
1393
1394 const bool is_selected = custom_asset_family_id_ == obj_id &&
1395 custom_asset_subtype_ == subtype;
1396 ImVec2 button_size(item_size, item_size);
1397
1398 if (ImGui::Selectable("", is_selected, 0, button_size)) {
1399 custom_asset_family_id_ = obj_id;
1400 custom_asset_subtype_ = subtype;
1402 }
1403 const bool item_visible = ImGui::IsItemVisible();
1404 ImVec2 button_pos = ImGui::GetItemRectMin();
1405 DrawObjectContextMenu(obj_id, subtype);
1406 if (asset_ready && rooms_ != nullptr &&
1407 rooms_->GetIfLoaded(current_room_id_) != nullptr) {
1409 static_cast<uint16_t>(obj_id), current_room_id_, 0, 0,
1411 static_cast<uint8_t>(subtype)));
1412 }
1413 ImDrawList* draw_list = ImGui::GetWindowDrawList();
1414 const bool show_id = item_size >= 44.0f;
1415 const float footer_height =
1416 show_id ? std::min(item_size * 0.32f, ImGui::GetFontSize() + 3.0f)
1417 : 0.0f;
1418 const float card_padding = std::min(3.0f, item_size * 0.08f);
1419 const ImVec2 preview_pos(button_pos.x + card_padding,
1420 button_pos.y + card_padding);
1421 const ImVec2 preview_box(
1422 std::max(1.0f, item_size - card_padding * 2.0f),
1423 std::max(1.0f, item_size - footer_height - card_padding * 2.0f));
1424
1425 bool rendered = false;
1426 if (item_visible && enable_object_previews_) {
1427 auto temp_obj = MakePreviewObject(obj_id);
1428 temp_obj.size_ = zelda3::CanonicalRoomObjectSize(
1429 obj_id, static_cast<uint8_t>(subtype));
1430 rendered = DrawObjectPreview(temp_obj, preview_pos, preview_box);
1431 }
1432
1433 if (item_visible && !rendered) {
1434 std::string sub_text = absl::StrFormat("%02X", subtype);
1435 DrawFallbackPreviewTile(
1436 draw_list, preview_pos, preview_box,
1437 asset_ready ? theme.status_success : theme.status_error,
1438 sub_text.c_str());
1439 }
1440
1441 const bool item_hovered = ImGui::IsItemHovered();
1442 if (item_visible && (is_selected || item_hovered)) {
1443 const ImU32 border_color =
1444 ImGui::GetColorU32(is_selected ? theme.dungeon_selection_primary
1445 : theme.panel_border_color);
1446 draw_list->AddRect(
1447 button_pos,
1448 ImVec2(button_pos.x + item_size, button_pos.y + item_size),
1449 border_color, 2.0f, 0, is_selected ? 2.0f : 1.0f);
1450 }
1451
1452 if (item_visible && show_id) {
1453 const ImVec2 card_bottom(button_pos.x + item_size,
1454 button_pos.y + item_size);
1455 const float footer_y = card_bottom.y - footer_height;
1456 draw_list->AddRectFilled(
1457 ImVec2(button_pos.x, footer_y), card_bottom,
1458 ImGui::GetColorU32(WithAlpha(theme.panel_bg_darker, 0.88f)),
1459 2.0f);
1460 std::string id_text = absl::StrFormat("%02X:%02X", obj_id, subtype);
1461 ImVec2 id_size = ImGui::CalcTextSize(id_text.c_str());
1462 ImVec2 id_pos = ImVec2(button_pos.x + (item_size - id_size.x) / 2,
1463 footer_y + (footer_height - id_size.y) * 0.5f);
1464 draw_list->AddText(id_pos, ImGui::GetColorU32(theme.text_primary),
1465 id_text.c_str());
1466 }
1467
1468 if (item_hovered) {
1469 gui::StyleColorGuard tooltip_guard(
1470 {{ImGuiCol_PopupBg, theme.panel_bg_color},
1471 {ImGuiCol_Border, theme.panel_border_color}});
1472 if (ImGui::BeginTooltip()) {
1473 const auto binding_or =
1474 obj_manager.ResolveSlotBinding(obj_id, subtype);
1475 const std::string filename =
1476 binding_or.ok() ? binding_or->filename : "";
1477
1478 ImGui::TextColored(theme.selection_primary,
1479 tr("Custom 0x%02X:%02X"), obj_id, subtype);
1480 ImGui::Text("%s", subtype_name.c_str());
1481 ImGui::TextColored(
1482 rendered ? theme.status_success : theme.status_warning,
1483 tr("Preview: %s"),
1484 rendered ? "rendered custom layout"
1485 : (enable_object_previews_ ? "fallback subtype"
1486 : "thumbnails off"));
1487 ImGui::Separator();
1488 ImGui::Text(tr("File: %s"),
1489 filename.empty() ? "(unmapped)" : filename.c_str());
1490 if (binding_or.ok()) {
1491 const ImVec4 mapping_color =
1492 binding_or->origin ==
1494 ? theme.status_active
1495 : (binding_or->origin ==
1496 zelda3::CustomObjectMappingOrigin::
1497 kConfiguredSlotUnmapped
1498 ? theme.status_error
1499 : theme.text_secondary_gray);
1500 ImGui::TextColored(
1501 mapping_color, tr("Mapping: %s"),
1502 tr(CustomObjectMappingOriginLabel(binding_or->origin)));
1503 }
1504 if (asset_ready) {
1505 ImGui::TextColored(theme.status_success,
1506 tr("Asset decoded and ready"));
1507 } else {
1508 ImGui::TextColored(theme.status_error, "%s",
1509 asset_status.message().data());
1510 }
1511
1512 ImGui::EndTooltip();
1513 }
1514 }
1515
1516 ImGui::PopID();
1517 custom_col = (custom_col + 1) % columns;
1518 }
1519 }
1520 }
1521 ImGui::Separator();
1522 const std::string selected_name = GetDungeonCustomObjectSlotName(
1524 const auto selected_binding_or = obj_manager.ResolveSlotBinding(
1526 const std::string selected_filename =
1527 selected_binding_or.ok() ? selected_binding_or->filename : "";
1528 const absl::Status selected_asset_status = GetCustomObjectAssetStatus(
1530 const auto selected_path_or =
1531 zelda3::ResolveCustomObjectAssetPath(custom_base_path, selected_filename);
1532 const bool selected_asset_ready = selected_asset_status.ok();
1533 ImGui::TextColored(theme.selection_primary, "0x%02X:%02X %s",
1535 selected_name.c_str());
1536 ImGui::SameLine();
1537 ImGui::TextDisabled("%s", selected_filename.empty()
1538 ? "(unmapped)"
1539 : selected_filename.c_str());
1540 if (selected_binding_or.ok()) {
1541 const ImVec4 mapping_color =
1542 selected_binding_or->origin ==
1544 ? theme.status_active
1545 : (selected_binding_or->origin ==
1546 zelda3::CustomObjectMappingOrigin::
1547 kConfiguredSlotUnmapped
1548 ? theme.status_error
1549 : theme.text_secondary_gray);
1550 ImGui::TextColored(
1551 mapping_color, tr("Mapping: %s"),
1552 tr(CustomObjectMappingOriginLabel(selected_binding_or->origin)));
1553 }
1554 if (selected_asset_ready) {
1555 ImGui::TextColored(theme.status_success,
1556 ICON_MD_CHECK_CIRCLE " Asset ready");
1557 if (selected_path_or.ok() && ImGui::IsItemHovered()) {
1558 ImGui::SetTooltip("%s", selected_path_or->string().c_str());
1559 }
1560 } else {
1561 ImGui::TextColored(theme.status_error, ICON_MD_ERROR " %s",
1562 selected_asset_status.message().data());
1563 }
1564 ImGui::PushTextWrapPos(0.0f);
1565 if (custom_asset_family_id_ == 0x31 && custom_asset_subtype_ == 13) {
1566 ImGui::TextDisabled(
1567 "%s", tr("Custom 0x31 wall object; separate from vanilla wall and "
1568 "corner tile tables"));
1571 ImGui::TextDisabled("%s", tr("Tile layout only; behavior is separate"));
1572 }
1573 if (custom_asset_family_id_ == 0x54) {
1574 ImGui::TextColored(theme.text_warning_yellow, ICON_MD_WARNING
1575 " Tilemap editing is available; external boss pixels "
1576 "are not loaded into the room preview yet.");
1577 if (ImGui::IsItemHovered()) {
1578 ImGui::SetTooltip(
1579 "%s",
1580 tr("Oracle forces nonzero body tiles into graphics page 0x300 and "
1581 "DMA-loads separate Kydreeok or Manhandla graphics at runtime. "
1582 "Yaze currently preserves the correct tilemap geometry and raw "
1583 "source words, but the displayed pixels are not visual-parity "
1584 "evidence."));
1585 }
1586 }
1587 ImGui::PopTextWrapPos();
1588 if (!custom_object_action_error_.empty()) {
1589 ImGui::TextColored(theme.status_error, ICON_MD_ERROR " %s",
1591 }
1592
1593 const bool has_room_context =
1594 rooms_ != nullptr && rooms_->GetIfLoaded(current_room_id_) != nullptr;
1595#if defined(__EMSCRIPTEN__)
1596 constexpr bool kCanPublishCustomAssets = false;
1597#else
1598 constexpr bool kCanPublishCustomAssets = true;
1599#endif
1600 const bool can_edit = selected_asset_ready && has_room_context &&
1601 tile_editor_panel_ != nullptr &&
1602 kCanPublishCustomAssets;
1603 const bool can_use_in_room = selected_asset_ready && has_room_context;
1604 if (ImGui::BeginTable(
1605 "##CustomAssetActions", 2,
1606 ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX)) {
1607 ImGui::TableNextColumn();
1608 if (!can_edit) {
1609 ImGui::BeginDisabled();
1610 }
1611 if (ImGui::Button(ICON_MD_EDIT " Edit Tile Layout", ImVec2(-1.0f, 0.0f))) {
1612 const absl::Status status = OpenExistingCustomObjectEditor(
1613 static_cast<int16_t>(custom_asset_family_id_), custom_asset_subtype_,
1615 if (status.ok()) {
1617 } else {
1618 custom_object_action_error_ = std::string(status.message());
1619 }
1620 }
1621 if (!can_edit) {
1622 ImGui::EndDisabled();
1623 }
1624 if (!kCanPublishCustomAssets &&
1625 ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1626 ImGui::SetTooltip(
1627 "%s", tr("Custom asset publishing is desktop-only until browser "
1628 "persistence can be verified."));
1629 }
1630
1631 ImGui::TableNextColumn();
1632 if (!can_use_in_room) {
1633 ImGui::BeginDisabled();
1634 }
1635 if (ImGui::Button(ICON_MD_ADD " Place in Room", ImVec2(-1.0f, 0.0f))) {
1638 }
1639 if (!can_use_in_room) {
1640 ImGui::EndDisabled();
1641 }
1642 ImGui::EndTable();
1643 }
1644
1647 const bool can_open_minecart =
1648 static_cast<bool>(open_minecart_editor_window_callback_);
1649 if (!can_open_minecart) {
1650 ImGui::BeginDisabled();
1651 }
1652 if (ImGui::Button(ICON_MD_TRAIN " Minecart Routes & Collision",
1653 ImVec2(-1.0f, 0.0f))) {
1656 } else {
1658 "Minecart Tracks window is unavailable in this session";
1659 }
1660 }
1661 if (!can_open_minecart) {
1662 ImGui::EndDisabled();
1663 }
1664 }
1665}
1666
1667} // namespace yaze::editor
auto data() const
Definition rom.h:201
bool is_loaded() const
Definition rom.h:187
static Flags & get()
Definition features.h:119
void SynchronizePreviewCacheRoomContext(const zelda3::Room &room)
std::function< void()> placement_invalidated_callback_
std::function< bool()> open_minecart_editor_window_callback_
absl::Status OpenExistingCustomObjectEditor(int16_t object_id, int subtype, int room_id)
std::map< uint32_t, absl::Status > custom_asset_status_cache_
void DrawObjectContextMenu(int object_id, int subtype=-1)
void CalculateObjectDimensions(const zelda3::RoomObject &object, int &width, int &height)
zelda3::DungeonObjectRegistry object_registry_
bool MatchesObjectSearch(int obj_id, const std::string &name, int subtype=-1) const
std::function< absl::Status(int, const zelda3::RoomObject &) edit_object_tiles_callback_)
void DrawObjectAssetBrowser(float minimum_grid_height=1.0f)
static bool IsRepresentableChestObjectId(int object_id)
absl::Status GetCustomObjectAssetStatus(int object_id, int subtype)
void SelectObject(int obj_id, int subtype=-1)
std::function< bool()> open_tile_editor_window_callback_
std::function< void(const zelda3::RoomObject &) object_selected_callback_)
std::map< uint64_t, std::unique_ptr< gfx::BackgroundBuffer > > preview_cache_
zelda3::RoomObject MakePreviewObject(int obj_id) const
std::map< uint32_t, zelda3::ObjectTileLayout > layout_cache_
bool GetOrCreatePreview(const zelda3::RoomObject &object, gfx::BackgroundBuffer **out)
static uint32_t MakeLayoutCacheKey(int object_id, uint8_t preview_size, const zelda3::Room *room)
bool MatchesObjectFilter(int obj_id, int filter_type)
bool DrawObjectPreview(const zelda3::RoomObject &object, ImVec2 top_left, ImVec2 box_size)
zelda3::Room * GetIfLoaded(int room_id)
absl::Status OpenForCustomObject(int16_t object_id, int subtype, int room_id, DungeonRoomStore *rooms)
void QueueTextureCommand(TextureCommandType type, Bitmap *bitmap)
Definition arena.cc:39
void RetireBitmap(Bitmap &bitmap)
Explicitly retire resources owned by a bitmap that is being erased.
Definition arena.cc:63
void ProcessTextureQueue(IRenderer *renderer)
Definition arena.cc:211
static Arena & Get()
Definition arena.cc:24
RAII guard for ImGui style colors.
Definition style_guard.h:27
RAII guard for ImGui style vars.
Definition style_guard.h:68
static const std::array< int, 3 > & RuntimeObjectIds()
static int RuntimeSubtypeCountForObject(int object_id)
static CustomObjectManager & Get()
absl::StatusOr< std::shared_ptr< CustomObject > > GetObjectInternal(int object_id, int subtype)
static DimensionService & Get()
std::pair< int, int > GetPixelDimensions(const RoomObject &obj) const
void RegisterVanillaRange(int16_t start_id, int16_t end_id)
Draws dungeon objects to background buffers using game patterns.
int GetDrawRoutineId(int16_t object_id) const
Get draw routine ID for an object.
Captures and edits the tile8 composition of dungeon objects.
absl::Status RenderLayoutToBitmap(const ObjectTileLayout &layout, gfx::Bitmap &bitmap, const uint8_t *room_gfx_buffer, const gfx::PaletteGroup &palette)
static bool IsEditableStandardObject(int16_t object_id)
absl::StatusOr< ObjectTileLayout > CaptureObjectLayout(int16_t object_id, const Room &room, const gfx::PaletteGroup &palette)
void SetRom(Rom *rom)
Definition room_object.h:78
const std::array< uint8_t, 0x10000 > & get_gfx_buffer() const
Definition room.h:1120
void MarkObjectsDirty()
Definition room.h:471
uint8_t blockset() const
Definition room.h:1015
uint8_t floor2() const
Definition room.h:1042
uint8_t palette() const
Definition room.h:1019
uint64_t graphics_revision() const
Definition room.h:1125
const std::vector< RoomObject > & GetTileObjects() const
Definition room.h:445
uint8_t floor1() const
Definition room.h:1041
uint8_t render_entrance_blockset() const
Definition room.h:1016
#define ICON_MD_INFO
Definition icons.h:993
#define ICON_MD_WARNING
Definition icons.h:2123
#define ICON_MD_TRAIN
Definition icons.h:2005
#define ICON_MD_REFRESH
Definition icons.h:1572
#define ICON_MD_EDIT
Definition icons.h:645
#define ICON_MD_ERROR
Definition icons.h:686
#define ICON_MD_ADD
Definition icons.h:86
#define ICON_MD_CHECK_CIRCLE
Definition icons.h:400
#define ICON_MD_FOLDER
Definition icons.h:809
#define ICON_MD_PRECISION_MANUFACTURING
Definition icons.h:1509
const AgentUITheme & GetTheme()
void DrawFallbackPreviewTile(ImDrawList *draw_list, ImVec2 top_left, ImVec2 size, const ImVec4 &accent_color, const char *label)
const char * CustomObjectMappingOriginLabel(zelda3::CustomObjectMappingOrigin origin)
Editors are the view controllers for the application.
bool MatchesDungeonObjectStreamFilter(int object_id, int selected_filter)
bool IsMinecartGraphicsRuntimeSlot(int object_id, int subtype)
bool IsDungeonCustomObjectRuntimeSlot(int object_id, int subtype)
DungeonObjectPreviewFit ResolveDungeonObjectPreviewFit(float source_width, float source_height, float box_width, float box_height)
std::string GetDungeonCustomObjectSlotName(int object_id, int subtype)
DungeonObjectSelectorGridLayout ResolveDungeonObjectSelectorGridLayout(float available_width, float preferred_item_size, float item_spacing, float min_item_size)
void AutoRegisterLastItem(const std::string &widget_type, const std::string &explicit_label, const std::string &description)
Automatically register the last ImGui item.
bool BeginRoomObjectDragSource(uint16_t object_id, int room_id, int pos_x, int pos_y, uint8_t size=0)
Definition drag_drop.h:99
absl::StatusOr< std::string > GetObjectCategory(int object_id)
Get category for a specific object.
uint8_t DefaultRoomObjectSizeForPlacement(int object_id)
int GetObjectSubtype(int object_id)
uint8_t CanonicalRoomObjectSize(int object_id, uint8_t requested_size)
DrawRoutineSymbology GetSymbologyForObject(int16_t object_id)
absl::StatusOr< fs::path > ResolveCustomObjectAssetPath(const std::string &custom_objects_folder, const std::string &filename)
std::string GetObjectName(int object_id)
constexpr int kNumberOfRooms
bool IsMinecartTrackGraphicsSubtype(int object_id, int subtype)
Compact, human-readable identity for an object's draw routine.
Automatic widget registration helpers for ImGui Test Engine integration.