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
24#include "app/gui/core/icons.h"
28#include "core/features.h"
29#include "rom/rom.h"
30#include "zelda3/dungeon/custom_object.h" // For CustomObjectManager
39#include "zelda3/dungeon/room.h"
40#include "zelda3/dungeon/room_object.h" // For GetObjectName()
41
42namespace yaze::editor {
43
44namespace {
45
47constexpr const char* kObjectGridDensityLabels[] = {"Compact", "Medium",
48 "Large"};
49
50float GetObjectGridItemSize(int density) {
51 switch (density) {
52 case 0:
53 return 54.0f;
54 case 2:
55 return 76.0f;
56 case 1:
57 default:
58 return 60.0f;
59 }
60}
61
62ImU32 ThemeColor(const ImVec4& color) {
63 return ImGui::ColorConvertFloat4ToU32(color);
64}
65
66ImVec4 WithAlpha(ImVec4 color, float alpha) {
67 color.w = alpha;
68 return color;
69}
70
71void DrawFallbackPreviewTile(ImDrawList* draw_list, ImVec2 top_left,
72 ImVec2 size, const ImVec4& accent_color,
73 const char* label) {
74 const auto& theme = AgentUI::GetTheme();
75 draw_list->AddRectFilled(
76 top_left, ImVec2(top_left.x + size.x, top_left.y + size.y),
77 ThemeColor(WithAlpha(theme.panel_bg_darker, 0.72f)), 2.0f);
78 draw_list->AddRectFilled(top_left,
79 ImVec2(top_left.x + 2.0f, top_left.y + size.y),
80 ThemeColor(WithAlpha(accent_color, 0.72f)), 2.0f);
81
82 ImVec2 label_size = ImGui::CalcTextSize(label);
83 if (label_size.x > size.x - 4.0f || label_size.y > size.y - 4.0f) {
84 return;
85 }
86 ImVec2 label_pos(top_left.x + (size.x - label_size.x) / 2,
87 top_left.y + (size.y - label_size.y) / 2);
88 draw_list->AddText(label_pos,
89 ThemeColor(WithAlpha(theme.text_primary, 0.82f)), label);
90}
91
94 switch (origin) {
96 return "Oracle default filename";
98 return "Configured filename";
100 return "Configured slot unmapped";
101 }
102 return "Unknown mapping";
103}
104
105} // namespace
106
108 float available_width, float preferred_item_size, float item_spacing,
109 float min_item_size) {
110 const float safe_spacing = std::max(item_spacing, 0.0f);
111 const float usable_width = std::max(available_width, 1.0f);
112 const float safe_min_item_size =
113 std::min(std::max(min_item_size, 1.0f), usable_width);
114 const float clamped_preferred_item_size =
115 std::clamp(preferred_item_size, safe_min_item_size, usable_width);
116
118 layout.columns = std::max(
119 1, static_cast<int>((usable_width + safe_spacing) /
120 (clamped_preferred_item_size + safe_spacing)));
121 layout.item_size = clamped_preferred_item_size;
122 const float total_spacing = safe_spacing * (layout.columns - 1);
123 const float occupied_width =
124 layout.item_size * layout.columns + total_spacing;
125 layout.leading_inset = std::max((usable_width - occupied_width) * 0.5f, 0.0f);
126 return layout;
127}
128
130 float source_height,
131 float box_width,
132 float box_height) {
134 if (source_width <= 0.0f || source_height <= 0.0f || box_width <= 0.0f ||
135 box_height <= 0.0f) {
136 return fit;
137 }
138
139 const float scale =
140 std::min(box_width / source_width, box_height / source_height);
141 fit.valid = true;
142 fit.width = source_width * scale;
143 fit.height = source_height * scale;
144 fit.x = (box_width - fit.width) * 0.5f;
145 fit.y = (box_height - fit.height) * 0.5f;
146 return fit;
147}
148
149bool MatchesDungeonObjectStreamFilter(int object_id, int selected_filter) {
150 switch (selected_filter) {
151 case 1:
152 return object_id >= 0x000 && object_id <= 0x0F7;
153 case 2:
154 return object_id >= 0x100 && object_id <= 0x13F;
155 case 3:
156 return object_id >= 0xF80 && object_id <= 0xFFF;
157 case 0:
158 default:
159 return true;
160 }
161}
162
163bool IsDungeonCustomObjectRuntimeSlot(int object_id, int subtype) {
164 return subtype >= 0 &&
166 object_id);
167}
168
169bool IsMinecartGraphicsRuntimeSlot(int object_id, int subtype) {
170 return object_id == 0x31 &&
171 zelda3::IsMinecartTrackGraphicsSubtype(object_id, subtype);
172}
173
174std::string GetDungeonCustomObjectSlotName(int object_id, int subtype) {
175 static constexpr std::array<const char*, 16> kObject31Names = {
176 "Track horizontal",
177 "Track vertical",
178 "Track corner top-left",
179 "Track corner top-right",
180 "Track corner bottom-left",
181 "Track corner bottom-right",
182 "Floor track vertical",
183 "Floor track horizontal",
184 "Floor track corner top-left",
185 "Floor track corner top-right",
186 "Floor track corner bottom-left",
187 "Floor track corner bottom-right",
188 "Floor track any direction",
189 "Sword House wall object",
190 "Track any direction",
191 "Small statue",
192 };
193 static constexpr std::array<const char*, 3> kObject32Names = {
194 "Ice furnace",
195 "Firewood",
196 "Ice chair",
197 };
198 static constexpr std::array<const char*, 2> kObject54Names = {
199 "Kydreeok body",
200 "Manhandla body",
201 };
202 if (object_id == 0x31 && subtype >= 0 &&
203 subtype < static_cast<int>(kObject31Names.size())) {
204 return kObject31Names[subtype];
205 }
206 if (object_id == 0x32 && subtype >= 0 &&
207 subtype < static_cast<int>(kObject32Names.size())) {
208 return kObject32Names[subtype];
209 }
210 if (object_id == 0x54 && subtype >= 0 &&
211 subtype < static_cast<int>(kObject54Names.size())) {
212 return kObject54Names[subtype];
213 }
214 return "Unknown custom runtime slot";
215}
216
220
222 const auto category = zelda3::ObjectCategories::GetObjectCategory(object_id);
223 return category.ok() && *category == "Chests";
224}
225
227 const auto& theme = AgentUI::GetTheme();
228 const auto category = zelda3::ObjectCategories::GetObjectCategory(object_id);
229 if (!category.ok())
230 return ImGui::GetColorU32(theme.dungeon_object_default);
231 if (*category == "Walls")
232 return ImGui::GetColorU32(theme.dungeon_object_wall);
233 if (*category == "Floors")
234 return ImGui::GetColorU32(theme.dungeon_object_floor);
235 if (*category == "Decorations")
236 return ImGui::GetColorU32(theme.dungeon_object_decoration);
237 if (*category == "Chests")
238 return ImGui::GetColorU32(theme.item_color);
239 if (*category == "Stairs")
240 return ImGui::GetColorU32(theme.selection_primary);
241 if (*category == "Doors")
242 return ImGui::GetColorU32(theme.transport_color);
243 if (*category == "Interactive")
244 return ImGui::GetColorU32(theme.status_success);
245 return ImGui::GetColorU32(theme.music_zone_color);
246}
247
249 const auto category = zelda3::ObjectCategories::GetObjectCategory(object_id);
250 if (!category.ok())
251 return "?";
252 if (*category == "Walls")
253 return "|";
254 if (*category == "Floors")
255 return "_";
256 if (*category == "Decorations")
257 return "~";
258 if (*category == "Chests")
259 return "C";
260 if (*category == "Stairs")
261 return "^";
262 if (*category == "Doors")
263 return "D";
264 if (*category == "Interactive")
265 return "o";
266 return "S";
267}
268
269void DungeonObjectSelector::SelectObject(int obj_id, int subtype) {
270 const int runtime_count =
272 if (runtime_count > 0 && subtype < 0 &&
273 core::FeatureFlags::get().kEnableCustomObjects) {
274 // In custom-enabled projects these IDs are dispatch families, not one
275 // subtype-free object. Selection must come from Custom Assets so the exact
276 // runtime slot and its decoded source asset are known.
277 return;
278 }
279 if (subtype >= 0) {
280 if ((runtime_count > 0 &&
281 !IsDungeonCustomObjectRuntimeSlot(obj_id, subtype)) ||
282 (runtime_count == 0 && subtype >= kPersistedCustomSubtypeSlots)) {
283 return;
284 }
285 if (runtime_count > 0) {
286 if (!core::FeatureFlags::get().kEnableCustomObjects) {
287 return;
288 }
290 if (!GetCustomObjectAssetStatus(obj_id, subtype).ok()) {
291 return;
292 }
293 }
294 }
295
296 selected_object_id_ = obj_id;
297
298 // Create and update preview object
299 uint8_t size = zelda3::DefaultRoomObjectSizeForPlacement(obj_id);
300 if (subtype >= 0) {
301 // Oracle custom objects use the explicitly selected size as a subtype.
302 size =
303 zelda3::CanonicalRoomObjectSize(obj_id, static_cast<uint8_t>(subtype));
304 }
305 preview_object_ = zelda3::RoomObject(obj_id, 0, 0, size, 0);
307 object_loaded_ = true;
312 .ok();
313
314 // Notify callback
317 }
318}
319
321 const auto& theme = AgentUI::GetTheme();
322
323 // Object ranges supported by the room-object stream codec.
324 struct ObjectRange {
325 int start;
326 int end;
327 const char* label;
328 };
329 static const ObjectRange ranges[] = {
330 {0x00, 0xF7, "Type 1"},
331 {0x100, 0x13F, "Type 2"},
332 {0xF80, 0xFFF, "Type 3"},
333 };
334
336 auto& obj_manager = zelda3::CustomObjectManager::Get();
337 int custom_count = 0;
338 for (const int object_id : zelda3::CustomObjectManager::RuntimeObjectIds()) {
339 custom_count += std::min(obj_manager.GetSubtypeCount(object_id),
340 kPersistedCustomSubtypeSlots);
341 }
342
343 const std::string custom_mode_label =
344 absl::StrFormat("Custom Assets (%d)", custom_count);
345 if (ImGui::BeginTable(
346 "##ObjectBrowserMode", 2,
347 ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX)) {
348 ImGui::TableNextColumn();
349 if (ImGui::Selectable(tr("Objects"), browser_mode_ == BrowserMode::kObjects,
350 0, ImVec2(ImGui::GetContentRegionAvail().x, 0.0f))) {
352 }
353 ImGui::TableNextColumn();
354 if (ImGui::Selectable(custom_mode_label.c_str(),
356 ImVec2(ImGui::GetContentRegionAvail().x, 0.0f))) {
358 }
359 ImGui::EndTable();
360 }
361
364 return;
365 }
366 ImGui::Spacing();
367
368 const ImGuiStyle& style = ImGui::GetStyle();
369 const float control_spacing = std::max(2.0f, style.ItemSpacing.x * 0.5f);
370 {
371 gui::StyleVarGuard toolbar_spacing_guard(
372 ImGuiStyleVar_ItemSpacing,
373 ImVec2(control_spacing, std::max(2.0f, style.ItemSpacing.y * 0.5f)));
374
375 const float toolbar_width =
376 std::max(ImGui::GetContentRegionAvail().x, 1.0f);
377 ImGui::SetNextItemWidth(toolbar_width);
378 ImGui::InputTextWithHint("##ObjectSearch", "Search by name or hex ID...",
380 sizeof(object_search_buffer_));
381
382 static const char* kFilterLabels[] = {
383 "All categories", "Walls", "Floors", "Chests",
384 "Doorways", "Decor", "Stairs"};
385 static const char* kStreamLabels[] = {"All streams", "Type 1", "Type 2",
386 "Type 3"};
387 constexpr const char* kMoreLabel = "More##ObjectSelectorMore";
388 const float more_width = ImGui::CalcTextSize(kMoreLabel, nullptr, true).x +
389 style.FramePadding.x * 2.0f;
390 const bool stack_filters = toolbar_width < 230.0f;
391 const float filter_row_width =
392 std::max(1.0f, toolbar_width - more_width - control_spacing);
393 const float category_width =
394 stack_filters ? toolbar_width : filter_row_width * 0.56f;
395 const float stream_width =
396 stack_filters ? filter_row_width
397 : std::max(1.0f, filter_row_width - category_width -
398 control_spacing);
399
400 ImGui::SetNextItemWidth(category_width);
401 ImGui::Combo("##ObjectFilterType", &object_type_filter_, kFilterLabels,
402 IM_ARRAYSIZE(kFilterLabels));
403 if (object_type_filter_ == 4 && ImGui::IsItemHovered()) {
404 ImGui::SetTooltip(
405 tr("Doorways are tile objects. Use the Door Editor "
406 "to place or change room doors."));
407 }
408 if (!stack_filters) {
409 ImGui::SameLine(0.0f, control_spacing);
410 }
411 ImGui::SetNextItemWidth(stream_width);
412 ImGui::Combo("##ObjectStreamFilter", &object_stream_filter_, kStreamLabels,
413 IM_ARRAYSIZE(kStreamLabels));
414 ImGui::SameLine(0.0f, control_spacing);
415 if (ImGui::Button(kMoreLabel, ImVec2(more_width, 0.0f))) {
416 ImGui::OpenPopup("##ObjectSelectorOptionsPopup");
417 }
418 if (ImGui::BeginPopup("##ObjectSelectorOptionsPopup")) {
419 const bool has_filters = object_search_buffer_[0] != '\0' ||
420 object_type_filter_ != 0 ||
422 if (ImGui::MenuItem(tr("Clear filters"), nullptr, false, has_filters)) {
423 object_search_buffer_[0] = '\0';
426 }
427
428 ImGui::SeparatorText(tr("Display"));
429 ImGui::Checkbox(tr("Show thumbnails"), &enable_object_previews_);
430 if (ImGui::IsItemHovered()) {
431 ImGui::SetTooltip(
432 tr("Show rendered object thumbnails in the selector.\n"
433 "Requires a room to be loaded and may cost some performance."));
434 }
435 ImGui::SeparatorText(tr("Card size"));
436 for (int density = 0; density < 3; ++density) {
437 if (ImGui::MenuItem(kObjectGridDensityLabels[density], nullptr,
438 object_grid_density_ == density)) {
439 object_grid_density_ = density;
440 }
441 }
442
443 ImGui::EndPopup();
444 }
445 }
446
447 // The grid is the selector's sole scroll owner. Calculate its geometry only
448 // after entering the child so themed padding and the scrollbar are included.
449 const float child_height = std::max(ImGui::GetContentRegionAvail().y, 1.0f);
450 if (ImGui::BeginChild("##ObjectGrid", ImVec2(0, child_height), false)) {
451 const float item_spacing = control_spacing;
452 // GetContentRegionAvail() already excludes the child window's scrollbar
453 // and padding. Centering the fixed-density grid balances any remainder so
454 // a resize cannot leave a second, artificial gutter on the right.
455 const auto grid_layout = ResolveDungeonObjectSelectorGridLayout(
456 ImGui::GetContentRegionAvail().x,
457 GetObjectGridItemSize(object_grid_density_), item_spacing);
458 const float item_size = grid_layout.item_size;
459 const int columns = grid_layout.columns;
460 gui::StyleVarGuard grid_spacing_guard(
461 ImGuiStyleVar_ItemSpacing,
462 ImVec2(item_spacing, std::max(item_spacing, style.ItemSpacing.y)));
463
464 // Iterate through all object ranges
465 for (const auto& range : ranges) {
466 if (!MatchesDungeonObjectStreamFilter(range.start,
468 continue;
469 }
470
471 if (object_stream_filter_ == 0) {
472 ImGui::TextDisabled("%s 0x%03X-0x%03X", range.label, range.start,
473 range.end);
474 }
475
476 int current_column = 0;
477
478 for (int obj_id = range.start; obj_id <= range.end; ++obj_id) {
481 0) {
482 // Custom-enabled runtime families are shown only in Custom Assets,
483 // where an exact subtype and validated source asset are required.
484 continue;
485 }
487 continue;
488 }
490 continue;
491 }
492
493 std::string full_name = zelda3::GetObjectName(obj_id);
494 if (!MatchesObjectSearch(obj_id, full_name)) {
495 continue;
496 }
497
498 if (current_column > 0) {
499 ImGui::SameLine(0.0f, item_spacing);
500 } else {
501 ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
502 grid_layout.leading_inset);
503 }
504
505 ImGui::PushID(obj_id);
506
507 // Create selectable button for object
508 bool is_selected = (selected_object_id_ == obj_id);
509 ImVec2 button_size(item_size, item_size);
510
511 if (ImGui::Selectable("", is_selected, 0, button_size)) {
512 SelectObject(obj_id);
513 }
514 const bool item_visible = ImGui::IsItemVisible();
515 // Routine symbology — one fast registry lookup per visible card only.
516 // Off-screen items are skipped entirely (no work queued until culled).
518 if (item_visible) {
519 sym = zelda3::GetSymbologyForObject(static_cast<int16_t>(obj_id));
520 }
521 ImVec2 button_pos = ImGui::GetItemRectMin();
523 static_cast<uint16_t>(obj_id), current_room_id_, 0, 0,
525 ImDrawList* draw_list = ImGui::GetWindowDrawList();
526 const bool show_id = item_size >= 32.0f;
527 const float footer_height =
528 show_id ? std::min(item_size * 0.32f, ImGui::GetFontSize() + 3.0f)
529 : 0.0f;
530 const float card_padding = std::min(3.0f, item_size * 0.08f);
531 const ImVec2 preview_pos(button_pos.x + card_padding,
532 button_pos.y + card_padding);
533 const ImVec2 preview_box(
534 std::max(1.0f, item_size - card_padding * 2.0f),
535 std::max(1.0f, item_size - footer_height - card_padding * 2.0f));
536
537 // Only attempt graphical preview if enabled (performance optimization)
538 bool rendered = false;
539 if (item_visible && enable_object_previews_) {
540 rendered = DrawObjectPreview(MakePreviewObject(obj_id), preview_pos,
541 preview_box);
542 }
543
544 if (item_visible && !rendered) {
545 std::string symbol = GetObjectTypeSymbol(obj_id);
546 DrawFallbackPreviewTile(
547 draw_list, preview_pos, preview_box,
548 ImGui::ColorConvertU32ToFloat4(GetObjectTypeColor(obj_id)),
549 symbol.c_str());
550 }
551
552 const bool item_hovered = ImGui::IsItemHovered();
553 if (item_visible && (is_selected || item_hovered)) {
554 const ImU32 border_color =
555 ImGui::GetColorU32(is_selected ? theme.dungeon_selection_primary
556 : theme.panel_border_color);
557 draw_list->AddRect(
558 button_pos,
559 ImVec2(button_pos.x + item_size, button_pos.y + item_size),
560 border_color, 2.0f, 0, is_selected ? 2.0f : 1.0f);
561 }
562
563 // Compact routine symbology badge — top-right corner overlay, drawn
564 // over both graphical thumbnails and fallback tiles. Only rendered
565 // for visible cards (sym is only populated when item_visible).
566 // Skipped for unmapped objects (badge == "?") to reduce visual noise.
567 if (item_visible && !sym.badge.empty() && sym.badge != "?") {
568 const ImVec2 badge_text_size =
569 ImGui::CalcTextSize(sym.badge.c_str(), nullptr, true);
570 const float badge_pad = 2.0f;
571 const float badge_w = badge_text_size.x + badge_pad * 2.0f;
572 const float badge_h = badge_text_size.y + badge_pad;
573 const ImVec2 badge_tl(button_pos.x + item_size - badge_w,
574 button_pos.y);
575 const ImVec2 badge_br(button_pos.x + item_size,
576 button_pos.y + badge_h);
577 draw_list->AddRectFilled(
578 badge_tl, badge_br,
579 ImGui::GetColorU32(WithAlpha(theme.panel_bg_darker, 0.82f)),
580 1.5f);
581 // BothBG routines use the wall-accent color; others use secondary
582 // text so the badge never competes with the thumbnail content.
583 const ImVec4& badge_color = sym.dual_layer
584 ? theme.dungeon_object_wall
585 : theme.text_secondary_gray;
586 draw_list->AddText(
587 ImVec2(badge_tl.x + badge_pad, badge_tl.y + badge_pad * 0.5f),
588 ImGui::GetColorU32(badge_color), sym.badge.c_str());
589 }
590
591 if (item_visible && show_id) {
592 const ImVec2 card_bottom(button_pos.x + item_size,
593 button_pos.y + item_size);
594 const float footer_y = card_bottom.y - footer_height;
595 draw_list->AddRectFilled(
596 ImVec2(button_pos.x, footer_y), card_bottom,
597 ImGui::GetColorU32(WithAlpha(theme.panel_bg_darker, 0.88f)),
598 2.0f);
599 std::string id_text = absl::StrFormat("%03X", obj_id);
600 ImVec2 id_size = ImGui::CalcTextSize(id_text.c_str());
601 ImVec2 id_pos = ImVec2(button_pos.x + (item_size - id_size.x) / 2,
602 footer_y + (footer_height - id_size.y) * 0.5f);
603 draw_list->AddText(id_pos, ImGui::GetColorU32(theme.text_primary),
604 id_text.c_str());
605 }
606
607 // Enhanced tooltip
608 if (item_hovered) {
609 gui::StyleColorGuard tooltip_guard(
610 {{ImGuiCol_PopupBg, theme.panel_bg_color},
611 {ImGuiCol_Border, theme.panel_border_color}});
612
613 if (ImGui::BeginTooltip()) {
614 ImGui::TextColored(theme.selection_primary, tr("Object 0x%03X"),
615 obj_id);
616 ImGui::Text("%s", full_name.c_str());
617 int subtype = zelda3::GetObjectSubtype(obj_id);
618 ImGui::TextColored(theme.text_secondary_gray, tr("Subtype %d"),
619 subtype);
620 ImGui::TextColored(
621 rendered ? theme.status_success : theme.status_warning,
622 tr("Preview: %s"),
623 rendered ? "rendered tile layout"
624 : (enable_object_previews_ ? "fallback symbol"
625 : "thumbnails off"));
626 ImGui::Separator();
627
628 const uint8_t preview_size =
630 const bool can_capture_layout =
631 rom_ && rooms_ && current_room_id_ >= 0 &&
633 const zelda3::Room* layout_room =
634 can_capture_layout ? &(*rooms_)[current_room_id_] : nullptr;
635 const uint32_t layout_key =
636 MakeLayoutCacheKey(obj_id, preview_size, layout_room);
637 if (can_capture_layout &&
638 layout_cache_.find(layout_key) == layout_cache_.end()) {
640 auto& room_ref = *layout_room;
641 auto layout_or = editor.CaptureObjectLayout(
642 obj_id, room_ref, current_palette_group_, preview_size);
643 if (layout_or.ok()) {
644 layout_cache_[layout_key] = layout_or.value();
645 }
646 }
647
648 if (layout_cache_.count(layout_key)) {
649 const auto& layout = layout_cache_[layout_key];
650 ImGui::TextColored(theme.status_success, tr("Tiles: %zu"),
651 layout.cells.size());
652
653 if (can_capture_layout) {
654 auto& room_ref = (*rooms_)[current_room_id_];
656 room_ref.get_gfx_buffer().data());
657 int rid = drawer.GetDrawRoutineId(obj_id);
658 ImGui::TextColored(theme.status_active, tr("Draw Routine: %d"),
659 rid);
660 }
661
662 ImGui::Text(tr("Layout:"));
663 ImDrawList* tooltip_draw_list = ImGui::GetWindowDrawList();
664 ImVec2 grid_start = ImGui::GetCursorScreenPos();
665 float cell_size = 4.0f;
666 for (const auto& cell : layout.cells) {
667 ImVec2 p1(grid_start.x + cell.rel_x * cell_size,
668 grid_start.y + cell.rel_y * cell_size);
669 ImVec2 p2(p1.x + cell_size, p1.y + cell_size);
670 tooltip_draw_list->AddRectFilled(
671 p1, p2, ThemeColor(theme.dungeon_grid_cell_highlight));
672 tooltip_draw_list->AddRect(
673 p1, p2, ThemeColor(theme.panel_border_color));
674 }
675 ImGui::Dummy(ImVec2(layout.bounds_width * cell_size,
676 layout.bounds_height * cell_size));
677 }
678
679 // Routine symbology context — built here (hover-only) so no
680 // strings are allocated for off-screen or non-hovered items.
681 // sym was computed when the card became visible; accessing it
682 // inside the tooltip block is always safe.
683 if (!sym.family.empty()) {
684 ImGui::Separator();
685 // Named specials (Chest, BigKeyLock, etc.) use item_color so
686 // they stand out; directional families use secondary text.
687 const bool is_named_special =
688 (sym.badge == "C" || sym.badge == "K" || sym.badge == "B" ||
689 sym.badge == "P");
690 if (is_named_special) {
691 ImGui::TextColored(theme.item_color, tr("Routine: %s"),
692 sym.family.c_str());
693 } else {
694 ImGui::TextColored(theme.text_secondary_gray, tr("Routine: %s"),
695 sym.family.c_str());
696 }
697 if (sym.dual_layer) {
698 ImGui::TextColored(theme.dungeon_object_wall,
699 tr(" BothBG: writes to BG1 and BG2"));
700 }
701 }
702
703 ImGui::Separator();
704 ImGui::TextColored(theme.text_secondary_gray,
705 tr("Click to select for placement"));
706 ImGui::EndTooltip();
707 }
708 }
709
710 ImGui::PopID();
711
712 current_column = (current_column + 1) % columns;
713 } // end object loop
714 } // end range loop
715 }
716
717 ImGui::EndChild();
718}
719
720bool DungeonObjectSelector::MatchesObjectFilter(int obj_id, int filter_type) {
721 constexpr std::array<const char*, 6> kCategories = {
722 "Walls", "Floors", "Chests", "Doors", "Decorations", "Stairs"};
723 if (filter_type < 1 || filter_type > static_cast<int>(kCategories.size())) {
724 return true;
725 }
726 const auto category = zelda3::ObjectCategories::GetObjectCategory(obj_id);
727 return category.ok() && *category == kCategories[filter_type - 1];
728}
729
731 const std::string& name,
732 int subtype) const {
733 if (object_search_buffer_[0] == '\0') {
734 return true;
735 }
736
737 auto to_lower = [](std::string value) {
738 std::transform(value.begin(), value.end(), value.begin(),
739 [](unsigned char c) { return std::tolower(c); });
740 return value;
741 };
742
743 std::string needle = to_lower(object_search_buffer_);
744 std::string name_lower = to_lower(name);
745
746 std::string id_hex = absl::StrFormat("%03X", obj_id);
747 std::string id_lower = to_lower(id_hex);
748 std::string id_pref = "0x" + id_lower;
749
750 if (name_lower.find(needle) != std::string::npos) {
751 return true;
752 }
753 if (id_lower.find(needle) != std::string::npos ||
754 id_pref.find(needle) != std::string::npos) {
755 return true;
756 }
757
758 if (subtype >= 0) {
759 std::string sub_hex = absl::StrFormat("%02X", subtype);
760 std::string sub_lower = to_lower(sub_hex);
761 std::string combined = id_lower + ":" + sub_lower;
762 std::string combined_pref = "0x" + combined;
763 if (combined.find(needle) != std::string::npos ||
764 combined_pref.find(needle) != std::string::npos) {
765 return true;
766 }
767 }
768
769 return false;
770}
771
773 const zelda3::RoomObject& object, int& width, int& height) {
775 width = std::min(w, 256);
776 height = std::min(h, 256);
777}
778
785
787 const bool custom_objects_enabled =
789 const uint64_t generation =
791 const bool custom_feature_changed =
792 custom_objects_enabled != observed_custom_objects_enabled_;
793 if (generation == observed_custom_object_generation_ &&
794 !custom_feature_changed) {
795 return;
796 }
797
798 if (rooms_ != nullptr) {
800 [](int, zelda3::Room& room) { room.MarkObjectsDirty(); });
801 }
804 observed_custom_objects_enabled_ = custom_objects_enabled;
805
806 const int object_id = preview_object_.id_;
807 const int subtype = preview_object_.size_ & 0x1F;
808 const bool fixed_runtime_slot =
809 IsDungeonCustomObjectRuntimeSlot(object_id, subtype);
810 auto& custom_object_manager = zelda3::CustomObjectManager::Get();
811 const bool exact_override_mapped_now =
812 !fixed_runtime_slot &&
813 !custom_object_manager.ResolveFilename(object_id, subtype).empty();
814 if (!object_loaded_ ||
815 (!fixed_runtime_slot && !preview_uses_custom_override_ &&
816 !exact_override_mapped_now)) {
817 return;
818 }
819
820 // A subtype-free vanilla selection for a custom runtime family stores an
821 // ordinary size in the
822 // same bits used by Oracle's custom dispatch. Never reinterpret that queued
823 // placement across either feature-state edge; require a fresh selection.
824 if (fixed_runtime_slot && custom_feature_changed) {
825 object_loaded_ = false;
830 }
831 return;
832 }
833
834 // With the feature stably disabled, these object IDs retain vanilla size
835 // semantics. A custom-asset cache or mapping change is irrelevant to that
836 // queued placement.
837 if (fixed_runtime_slot && !custom_objects_enabled) {
838 return;
839 }
840
841 if (fixed_runtime_slot &&
842 !GetCustomObjectAssetStatus(object_id, subtype).ok()) {
843 object_loaded_ = false;
848 }
849 return;
850 }
851
852 if (!fixed_runtime_slot) {
854 custom_objects_enabled &&
855 custom_object_manager.GetObjectInternal(object_id, subtype).ok();
856 }
857
860 }
861}
862
881
883 int subtype) {
884 if (!IsDungeonCustomObjectRuntimeSlot(object_id, subtype)) {
885 return absl::OutOfRangeError(
886 "Custom object subtype is outside the runtime dispatch table");
887 }
888
889 const uint32_t key =
890 (static_cast<uint32_t>(object_id) << 8) | static_cast<uint32_t>(subtype);
891 if (const auto cached = custom_asset_status_cache_.find(key);
892 cached != custom_asset_status_cache_.end()) {
893 return cached->second;
894 }
895
896 auto object_or =
898 absl::Status status = object_or.ok() ? absl::OkStatus() : object_or.status();
899 custom_asset_status_cache_.emplace(key, status);
900 return status;
901}
902
904 zelda3::RoomObject obj(obj_id, 0, 0,
906 obj.SetRom(rom_);
907 return obj;
908}
909
916
918 auto& arena = gfx::Arena::Get();
919 for (auto& [key, preview] : preview_cache_) {
920 (void)key;
921 if (preview != nullptr) {
922 arena.RetireBitmap(preview->bitmap());
923 }
924 }
925 preview_cache_.clear();
926}
927
949
951 uint8_t preview_size,
952 const zelda3::Room* room) {
953 uint8_t room_floor = 0;
954 if (room != nullptr) {
955 if (object_id == 0xC4) {
956 room_floor = room->floor1() & 0x0F;
957 } else if (object_id == 0xDB) {
958 room_floor = room->floor2() & 0x0F;
959 }
960 }
961
962 return (static_cast<uint32_t>(object_id) << 16) |
963 (static_cast<uint32_t>(preview_size) << 8) | room_floor;
964}
965
967 gfx::BackgroundBuffer** out) {
968 if (out == nullptr) {
969 return false;
970 }
971 *out = nullptr;
972 if (!rom_ || !rom_->is_loaded()) {
973 return false;
974 }
976
977 // Check if room context changed - invalidate cache if so
978 const zelda3::Room* room =
979 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
980 if (room == nullptr) {
981 return false;
982 }
984
985 // Check if already in cache
986 // Key: object, subtype, blockset, palette, and both room floor nibbles.
987 // Room/entrance changes clear the complete cache before this lookup.
988 const uint8_t preview_size =
989 zelda3::CanonicalRoomObjectSize(object.id_, object.size());
990 int subtype = preview_size & 0x1F;
991 uint64_t cache_key =
992 (static_cast<uint64_t>(object.id_) << 32) |
993 (static_cast<uint64_t>(subtype) << 24) |
994 (static_cast<uint64_t>(cached_preview_blockset_) << 16) |
995 (static_cast<uint64_t>(cached_preview_palette_) << 8) |
996 (static_cast<uint64_t>(cached_preview_floor1_ & 0x0F) << 4) |
997 static_cast<uint64_t>(cached_preview_floor2_ & 0x0F);
998
999 auto it = preview_cache_.find(cache_key);
1000 if (it != preview_cache_.end()) {
1001 *out = it->second.get();
1002 return (*out)->bitmap().texture() != nullptr;
1003 }
1004
1005 // Create new preview using ObjectTileEditor
1006 const uint8_t* gfx_data = room->get_gfx_buffer().data();
1007
1009 auto layout_or = editor.CaptureObjectLayout(
1010 object.id_, *room, current_palette_group_, preview_size);
1011 if (!layout_or.ok()) {
1012 return false;
1013 }
1014 const auto& layout = layout_or.value();
1015
1016 // Create preview buffer large enough for object
1017 int bmp_w = std::max(8, layout.bounds_width * 8);
1018 int bmp_h = std::max(8, layout.bounds_height * 8);
1019 auto preview = std::make_unique<gfx::BackgroundBuffer>(bmp_w, bmp_h);
1020 preview->EnsureBitmapInitialized();
1021
1022 // Render layout to bitmap
1023 auto render_status = editor.RenderLayoutToBitmap(
1024 layout, preview->bitmap(), gfx_data, current_palette_group_);
1025 if (!render_status.ok()) {
1026 gfx::Arena::Get().RetireBitmap(preview->bitmap());
1027 return false;
1028 }
1029
1030 auto& bitmap = preview->bitmap();
1031 // Texture creation and SDL sync
1032 if (!bitmap.surface()) {
1033 gfx::Arena::Get().RetireBitmap(bitmap);
1034 return false;
1035 }
1036 SDL_LockSurface(bitmap.surface());
1037 memcpy(bitmap.surface()->pixels, bitmap.mutable_data().data(),
1038 bitmap.mutable_data().size());
1039 SDL_UnlockSurface(bitmap.surface());
1040
1041 // Install the owner before queuing CREATE. The renderer may defer this
1042 // command until DoRender, so the Bitmap address must remain valid even when
1043 // this frame falls back to the symbolic preview.
1044 auto [cache_it, inserted] =
1045 preview_cache_.try_emplace(cache_key, std::move(preview));
1046 if (!inserted) {
1047 if (preview != nullptr) {
1048 gfx::Arena::Get().RetireBitmap(preview->bitmap());
1049 }
1050 *out = cache_it->second.get();
1051 return (*out)->bitmap().texture() != nullptr;
1052 }
1053
1054 *out = cache_it->second.get();
1055 auto& cached_bitmap = (*out)->bitmap();
1057 &cached_bitmap);
1059
1060 // A null texture is an expected deferred state when the Arena has no active
1061 // renderer yet. Keep the cache entry and its queued owner alive; the next
1062 // frame will draw it after DoRender processes CREATE.
1063 return cached_bitmap.texture() != nullptr;
1064}
1065
1067 ImVec2 top_left,
1068 ImVec2 box_size) {
1069 gfx::BackgroundBuffer* preview = nullptr;
1070 if (!GetOrCreatePreview(object, &preview)) {
1071 return false;
1072 }
1073
1074 // Draw the cached preview image
1075 auto& bitmap = preview->bitmap();
1076 if (!bitmap.texture()) {
1077 return false;
1078 }
1079
1081 static_cast<float>(bitmap.width()), static_cast<float>(bitmap.height()),
1082 box_size.x, box_size.y);
1083 if (!fit.valid) {
1084 return false;
1085 }
1086
1087 const ImVec2 image_top_left(top_left.x + fit.x, top_left.y + fit.y);
1088 const ImVec2 image_bottom_right(image_top_left.x + fit.width,
1089 image_top_left.y + fit.height);
1090 ImGui::GetWindowDrawList()->AddImage((ImTextureID)(intptr_t)bitmap.texture(),
1091 image_top_left, image_bottom_right);
1092 return true;
1093}
1094
1096 int16_t object_id, int subtype, int room_id) {
1097 if (tile_editor_panel_ == nullptr) {
1098 return absl::FailedPreconditionError(
1099 "Object Tile Editor panel is unavailable");
1100 }
1101
1102 const absl::Status open_status = tile_editor_panel_->OpenForCustomObject(
1103 object_id, subtype, room_id, rooms_, current_palette_group_);
1104 if (!open_status.ok()) {
1107 }
1108 return open_status;
1109 }
1113 return absl::NotFoundError(
1114 "Object Tile Editor window is not registered in this session");
1115 }
1116 return absl::OkStatus();
1117}
1118
1120 const auto& theme = AgentUI::GetTheme();
1121 auto& obj_manager = zelda3::CustomObjectManager::Get();
1122 const std::string custom_base_path = obj_manager.GetBasePath();
1123
1124 ImGui::TextColored(theme.text_info,
1125 ICON_MD_PRECISION_MANUFACTURING " Custom Assets");
1126 ImGui::PushTextWrapPos(0.0f);
1127 ImGui::TextColored(
1128 theme.text_secondary_gray,
1129 tr("Manage the 21 fixed runtime assets used by Oracle custom objects."));
1130 ImGui::TextDisabled(
1131 "%s", tr("New subtypes require an ASM dispatch-table change; this "
1132 "browser edits or places existing slots only."));
1133 ImGui::PopTextWrapPos();
1134 ImGui::Separator();
1135
1136 if (ImGui::BeginTable(
1137 "##CustomObjectToolbar", 2,
1138 ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_NoPadOuterX)) {
1139 ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 2.0f);
1140 ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed,
1141 180.0f);
1142 ImGui::TableNextRow();
1143
1144 ImGui::TableNextColumn();
1145 if (custom_base_path.empty()) {
1146 ImGui::TextColored(theme.text_warning_yellow, ICON_MD_WARNING
1147 " Custom object folder is not configured.");
1148 } else {
1149 ImGui::TextColored(theme.text_secondary_gray,
1150 ICON_MD_FOLDER " Asset folder");
1151 if (ImGui::IsItemHovered()) {
1152 ImGui::SetTooltip("%s", custom_base_path.c_str());
1153 }
1154 }
1155
1156 ImGui::TableNextColumn();
1157 if (ImGui::Button(ICON_MD_REFRESH " Reload Assets", ImVec2(-1, 0))) {
1158 obj_manager.ReloadAll();
1161 }
1162 if (ImGui::IsItemHovered()) {
1163 ImGui::SetTooltip(
1164 tr("Refresh custom object and external sprite previews from disk. "
1165 "Unsaved room and tile edits are kept."));
1166 }
1167 ImGui::EndTable();
1168 }
1169
1170 if (!core::FeatureFlags::get().kEnableCustomObjects) {
1171 ImGui::TextColored(
1172 theme.text_warning_yellow, ICON_MD_WARNING
1173 " Custom Objects is disabled for this project. Enable the feature "
1174 "before editing or placing runtime assets.");
1175 return;
1176 }
1177
1178 ImGui::TextDisabled("%s", tr("Card size"));
1179 ImGui::SameLine();
1180 ImGui::SetNextItemWidth(-1.0f);
1181 ImGui::Combo("##CustomAssetCardSize", &object_grid_density_,
1182 kObjectGridDensityLabels,
1183 IM_ARRAYSIZE(kObjectGridDensityLabels));
1184
1185 ImGui::PushTextWrapPos(0.0f);
1186 ImGui::TextColored(
1187 theme.text_secondary_gray, ICON_MD_INFO
1188 " Track tile layouts are assets here; routes and collision are managed "
1189 "in Minecart Tracks.");
1190 ImGui::PopTextWrapPos();
1191 ImGui::Spacing();
1192
1193 struct CustomAssetFamily {
1194 int object_id;
1195 const char* label;
1196 const char* description;
1197 };
1198 static constexpr std::array<CustomAssetFamily, 3> kFamilies = {{
1199 {0x31, "Tracks + Props (16)",
1200 "Minecart tile layouts, the Sword House wall object, and a small "
1201 "statue"},
1202 {0x32, "Ice Props (3)", "Furnace, firewood, and chair assets"},
1203 {0x54, "Boss Bodies (2)", "Kydreeok and Manhandla body tilemaps"},
1204 }};
1205 const CustomAssetFamily* selected_family = &kFamilies.front();
1206 for (const auto& family : kFamilies) {
1207 if (custom_asset_family_id_ == family.object_id) {
1208 selected_family = &family;
1209 break;
1210 }
1211 }
1212 ImGui::TextDisabled("%s", tr("Asset family"));
1213 ImGui::SetNextItemWidth(-1.0f);
1214 if (ImGui::BeginCombo("##CustomAssetFamily", selected_family->label)) {
1215 for (const auto& family : kFamilies) {
1216 const bool selected = custom_asset_family_id_ == family.object_id;
1217 if (ImGui::Selectable(family.label, selected)) {
1218 custom_asset_family_id_ = family.object_id;
1220 selected_family = &family;
1222 }
1223 if (selected) {
1224 ImGui::SetItemDefaultFocus();
1225 }
1226 }
1227 ImGui::EndCombo();
1228 }
1229 ImGui::PushTextWrapPos(0.0f);
1230 ImGui::TextDisabled("%s", tr(selected_family->description));
1231 ImGui::PopTextWrapPos();
1232
1233 {
1234 const ImGuiStyle& style = ImGui::GetStyle();
1235 const float item_spacing = std::max(2.0f, style.ItemSpacing.x * 0.5f);
1236 const auto grid_layout = ResolveDungeonObjectSelectorGridLayout(
1237 ImGui::GetContentRegionAvail().x,
1238 GetObjectGridItemSize(object_grid_density_), item_spacing);
1239 const int columns = grid_layout.columns;
1240 const float item_size = grid_layout.item_size;
1241 gui::StyleVarGuard grid_spacing_guard(
1242 ImGuiStyleVar_ItemSpacing,
1243 ImVec2(item_spacing, std::max(item_spacing, style.ItemSpacing.y)));
1244 int custom_col = 0;
1245 for (const int obj_id : zelda3::CustomObjectManager::RuntimeObjectIds()) {
1246 if (obj_id != custom_asset_family_id_) {
1247 continue;
1248 }
1249 const int subtype_count = std::min(obj_manager.GetSubtypeCount(obj_id),
1250 kPersistedCustomSubtypeSlots);
1251 for (int subtype = 0; subtype < subtype_count; ++subtype) {
1252 const std::string subtype_name =
1253 GetDungeonCustomObjectSlotName(obj_id, subtype);
1254
1255 if (custom_col > 0) {
1256 ImGui::SameLine(0.0f, item_spacing);
1257 } else {
1258 ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
1259 grid_layout.leading_inset);
1260 }
1261
1262 ImGui::PushID(obj_id * 1000 + subtype);
1263
1264 const absl::Status asset_status =
1265 GetCustomObjectAssetStatus(obj_id, subtype);
1266 const bool asset_ready = asset_status.ok();
1267
1268 const bool is_selected = custom_asset_family_id_ == obj_id &&
1269 custom_asset_subtype_ == subtype;
1270 ImVec2 button_size(item_size, item_size);
1271
1272 if (ImGui::Selectable("", is_selected, 0, button_size)) {
1273 custom_asset_family_id_ = obj_id;
1274 custom_asset_subtype_ = subtype;
1276 }
1277 const bool item_visible = ImGui::IsItemVisible();
1278 ImVec2 button_pos = ImGui::GetItemRectMin();
1279 if (asset_ready && rooms_ != nullptr &&
1280 rooms_->GetIfLoaded(current_room_id_) != nullptr) {
1282 static_cast<uint16_t>(obj_id), current_room_id_, 0, 0,
1284 static_cast<uint8_t>(subtype)));
1285 }
1286 ImDrawList* draw_list = ImGui::GetWindowDrawList();
1287 const bool show_id = item_size >= 44.0f;
1288 const float footer_height =
1289 show_id ? std::min(item_size * 0.32f, ImGui::GetFontSize() + 3.0f)
1290 : 0.0f;
1291 const float card_padding = std::min(3.0f, item_size * 0.08f);
1292 const ImVec2 preview_pos(button_pos.x + card_padding,
1293 button_pos.y + card_padding);
1294 const ImVec2 preview_box(
1295 std::max(1.0f, item_size - card_padding * 2.0f),
1296 std::max(1.0f, item_size - footer_height - card_padding * 2.0f));
1297
1298 bool rendered = false;
1299 if (item_visible && enable_object_previews_) {
1300 auto temp_obj = MakePreviewObject(obj_id);
1301 temp_obj.size_ = zelda3::CanonicalRoomObjectSize(
1302 obj_id, static_cast<uint8_t>(subtype));
1303 rendered = DrawObjectPreview(temp_obj, preview_pos, preview_box);
1304 }
1305
1306 if (item_visible && !rendered) {
1307 std::string sub_text = absl::StrFormat("%02X", subtype);
1308 DrawFallbackPreviewTile(
1309 draw_list, preview_pos, preview_box,
1310 asset_ready ? theme.status_success : theme.status_error,
1311 sub_text.c_str());
1312 }
1313
1314 const bool item_hovered = ImGui::IsItemHovered();
1315 if (item_visible && (is_selected || item_hovered)) {
1316 const ImU32 border_color =
1317 ImGui::GetColorU32(is_selected ? theme.dungeon_selection_primary
1318 : theme.panel_border_color);
1319 draw_list->AddRect(
1320 button_pos,
1321 ImVec2(button_pos.x + item_size, button_pos.y + item_size),
1322 border_color, 2.0f, 0, is_selected ? 2.0f : 1.0f);
1323 }
1324
1325 if (item_visible && show_id) {
1326 const ImVec2 card_bottom(button_pos.x + item_size,
1327 button_pos.y + item_size);
1328 const float footer_y = card_bottom.y - footer_height;
1329 draw_list->AddRectFilled(
1330 ImVec2(button_pos.x, footer_y), card_bottom,
1331 ImGui::GetColorU32(WithAlpha(theme.panel_bg_darker, 0.88f)),
1332 2.0f);
1333 std::string id_text = absl::StrFormat("%02X:%02X", obj_id, subtype);
1334 ImVec2 id_size = ImGui::CalcTextSize(id_text.c_str());
1335 ImVec2 id_pos = ImVec2(button_pos.x + (item_size - id_size.x) / 2,
1336 footer_y + (footer_height - id_size.y) * 0.5f);
1337 draw_list->AddText(id_pos, ImGui::GetColorU32(theme.text_primary),
1338 id_text.c_str());
1339 }
1340
1341 if (item_hovered) {
1342 gui::StyleColorGuard tooltip_guard(
1343 {{ImGuiCol_PopupBg, theme.panel_bg_color},
1344 {ImGuiCol_Border, theme.panel_border_color}});
1345 if (ImGui::BeginTooltip()) {
1346 const auto binding_or =
1347 obj_manager.ResolveSlotBinding(obj_id, subtype);
1348 const std::string filename =
1349 binding_or.ok() ? binding_or->filename : "";
1350
1351 ImGui::TextColored(theme.selection_primary,
1352 tr("Custom 0x%02X:%02X"), obj_id, subtype);
1353 ImGui::Text("%s", subtype_name.c_str());
1354 ImGui::TextColored(
1355 rendered ? theme.status_success : theme.status_warning,
1356 tr("Preview: %s"),
1357 rendered ? "rendered custom layout"
1358 : (enable_object_previews_ ? "fallback subtype"
1359 : "thumbnails off"));
1360 ImGui::Separator();
1361 ImGui::Text(tr("File: %s"),
1362 filename.empty() ? "(unmapped)" : filename.c_str());
1363 if (binding_or.ok()) {
1364 const ImVec4 mapping_color =
1365 binding_or->origin ==
1367 ? theme.status_active
1368 : (binding_or->origin ==
1369 zelda3::CustomObjectMappingOrigin::
1370 kConfiguredSlotUnmapped
1371 ? theme.status_error
1372 : theme.text_secondary_gray);
1373 ImGui::TextColored(
1374 mapping_color, tr("Mapping: %s"),
1375 tr(CustomObjectMappingOriginLabel(binding_or->origin)));
1376 }
1377 if (asset_ready) {
1378 ImGui::TextColored(theme.status_success,
1379 tr("Asset decoded and ready"));
1380 } else {
1381 ImGui::TextColored(theme.status_error, "%s",
1382 asset_status.message().data());
1383 }
1384
1385 ImGui::EndTooltip();
1386 }
1387 }
1388
1389 ImGui::PopID();
1390 custom_col = (custom_col + 1) % columns;
1391 }
1392 }
1393 }
1394 ImGui::Separator();
1395 const std::string selected_name = GetDungeonCustomObjectSlotName(
1397 const auto selected_binding_or = obj_manager.ResolveSlotBinding(
1399 const std::string selected_filename =
1400 selected_binding_or.ok() ? selected_binding_or->filename : "";
1401 const absl::Status selected_asset_status = GetCustomObjectAssetStatus(
1403 const auto selected_path_or =
1404 zelda3::ResolveCustomObjectAssetPath(custom_base_path, selected_filename);
1405 const bool selected_asset_ready = selected_asset_status.ok();
1406 ImGui::TextColored(theme.selection_primary, "0x%02X:%02X %s",
1408 selected_name.c_str());
1409 ImGui::SameLine();
1410 ImGui::TextDisabled("%s", selected_filename.empty()
1411 ? "(unmapped)"
1412 : selected_filename.c_str());
1413 if (selected_binding_or.ok()) {
1414 const ImVec4 mapping_color =
1415 selected_binding_or->origin ==
1417 ? theme.status_active
1418 : (selected_binding_or->origin ==
1419 zelda3::CustomObjectMappingOrigin::
1420 kConfiguredSlotUnmapped
1421 ? theme.status_error
1422 : theme.text_secondary_gray);
1423 ImGui::TextColored(
1424 mapping_color, tr("Mapping: %s"),
1425 tr(CustomObjectMappingOriginLabel(selected_binding_or->origin)));
1426 }
1427 if (selected_asset_ready) {
1428 ImGui::TextColored(theme.status_success,
1429 ICON_MD_CHECK_CIRCLE " Asset ready");
1430 if (selected_path_or.ok() && ImGui::IsItemHovered()) {
1431 ImGui::SetTooltip("%s", selected_path_or->string().c_str());
1432 }
1433 } else {
1434 ImGui::TextColored(theme.status_error, ICON_MD_ERROR " %s",
1435 selected_asset_status.message().data());
1436 }
1437 ImGui::PushTextWrapPos(0.0f);
1438 if (custom_asset_family_id_ == 0x31 && custom_asset_subtype_ == 13) {
1439 ImGui::TextDisabled(
1440 "%s", tr("Custom 0x31 wall object; separate from vanilla wall and "
1441 "corner tile tables"));
1444 ImGui::TextDisabled("%s", tr("Tile layout only; behavior is separate"));
1445 }
1446 if (custom_asset_family_id_ == 0x54) {
1447 ImGui::TextColored(theme.text_warning_yellow, ICON_MD_WARNING
1448 " Tilemap editing is available; external boss pixels "
1449 "are not loaded into the room preview yet.");
1450 if (ImGui::IsItemHovered()) {
1451 ImGui::SetTooltip(
1452 "%s",
1453 tr("Oracle forces nonzero body tiles into graphics page 0x300 and "
1454 "DMA-loads separate Kydreeok or Manhandla graphics at runtime. "
1455 "Yaze currently preserves the correct tilemap geometry and raw "
1456 "source words, but the displayed pixels are not visual-parity "
1457 "evidence."));
1458 }
1459 }
1460 ImGui::PopTextWrapPos();
1461 if (!custom_object_action_error_.empty()) {
1462 ImGui::TextColored(theme.status_error, ICON_MD_ERROR " %s",
1464 }
1465
1466 const bool has_room_context =
1467 rooms_ != nullptr && rooms_->GetIfLoaded(current_room_id_) != nullptr;
1468#if defined(__EMSCRIPTEN__)
1469 constexpr bool kCanPublishCustomAssets = false;
1470#else
1471 constexpr bool kCanPublishCustomAssets = true;
1472#endif
1473 const bool can_edit = selected_asset_ready && has_room_context &&
1474 tile_editor_panel_ != nullptr &&
1475 kCanPublishCustomAssets;
1476 const bool can_use_in_room = selected_asset_ready && has_room_context;
1477 if (ImGui::BeginTable(
1478 "##CustomAssetActions", 2,
1479 ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX)) {
1480 ImGui::TableNextColumn();
1481 if (!can_edit) {
1482 ImGui::BeginDisabled();
1483 }
1484 if (ImGui::Button(ICON_MD_EDIT " Edit Tile Layout", ImVec2(-1.0f, 0.0f))) {
1485 const absl::Status status = OpenExistingCustomObjectEditor(
1486 static_cast<int16_t>(custom_asset_family_id_), custom_asset_subtype_,
1488 if (status.ok()) {
1490 } else {
1491 custom_object_action_error_ = std::string(status.message());
1492 }
1493 }
1494 if (!can_edit) {
1495 ImGui::EndDisabled();
1496 }
1497 if (!kCanPublishCustomAssets &&
1498 ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1499 ImGui::SetTooltip(
1500 "%s", tr("Custom asset publishing is desktop-only until browser "
1501 "persistence can be verified."));
1502 }
1503
1504 ImGui::TableNextColumn();
1505 if (!can_use_in_room) {
1506 ImGui::BeginDisabled();
1507 }
1508 if (ImGui::Button(ICON_MD_ADD " Place in Room", ImVec2(-1.0f, 0.0f))) {
1511 }
1512 if (!can_use_in_room) {
1513 ImGui::EndDisabled();
1514 }
1515 ImGui::EndTable();
1516 }
1517
1520 const bool can_open_minecart =
1521 static_cast<bool>(open_minecart_editor_window_callback_);
1522 if (!can_open_minecart) {
1523 ImGui::BeginDisabled();
1524 }
1525 if (ImGui::Button(ICON_MD_TRAIN " Minecart Routes & Collision",
1526 ImVec2(-1.0f, 0.0f))) {
1529 } else {
1531 "Minecart Tracks window is unavailable in this session";
1532 }
1533 }
1534 if (!can_open_minecart) {
1535 ImGui::EndDisabled();
1536 }
1537 }
1538}
1539
1540} // namespace yaze::editor
auto data() const
Definition rom.h:169
bool is_loaded() const
Definition rom.h:155
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 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
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)
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:1019
void MarkObjectsDirty()
Definition room.h:431
uint8_t blockset() const
Definition room.h:951
uint8_t floor2() const
Definition room.h:976
uint8_t palette() const
Definition room.h:954
uint64_t graphics_revision() const
Definition room.h:1024
uint8_t floor1() const
Definition room.h:975
uint8_t render_entrance_blockset() const
Definition room.h:952
#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)
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.