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
52 ImVec2 position;
53 ImVec2 size;
54};
55
57 const BrowserNameStrip strip{
58 ImGui::GetCursorScreenPos(),
59 ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x),
60 ImGui::GetTextLineHeight())};
61 ImGui::Dummy(strip.size);
62 return strip;
63}
64
66 const std::string& label) {
67 // Paint after the grid has resolved hover, into space reserved before it.
68 // Long names stay on one line and never move the cards under the pointer.
69 auto* draw_list = ImGui::GetWindowDrawList();
70 const ImVec2 end(strip.position.x + strip.size.x,
71 strip.position.y + strip.size.y);
72 draw_list->PushClipRect(strip.position, end, true);
73 draw_list->AddText(strip.position,
74 ImGui::GetColorU32(AgentUI::GetTheme().text_primary),
75 label.c_str());
76 draw_list->PopClipRect();
77}
78
79float GetObjectGridItemSize(int density) {
80 switch (density) {
81 case 0:
82 return 64.0f;
83 case 2:
84 return 112.0f;
85 case 1:
86 default:
87 return 84.0f;
88 }
89}
90
91ImU32 ThemeColor(const ImVec4& color) {
92 return ImGui::ColorConvertFloat4ToU32(color);
93}
94
95ImVec4 WithAlpha(ImVec4 color, float alpha) {
96 color.w = alpha;
97 return color;
98}
99
100void DrawFallbackPreviewTile(ImDrawList* draw_list, ImVec2 top_left,
101 ImVec2 size, const ImVec4& accent_color,
102 const char* label) {
103 const auto& theme = AgentUI::GetTheme();
104 draw_list->AddRectFilled(
105 top_left, ImVec2(top_left.x + size.x, top_left.y + size.y),
106 ThemeColor(WithAlpha(theme.panel_bg_darker, 0.72f)), 2.0f);
107 draw_list->AddRectFilled(top_left,
108 ImVec2(top_left.x + 2.0f, top_left.y + size.y),
109 ThemeColor(WithAlpha(accent_color, 0.72f)), 2.0f);
110
111 ImVec2 label_size = ImGui::CalcTextSize(label);
112 if (label_size.x > size.x - 4.0f || label_size.y > size.y - 4.0f) {
113 return;
114 }
115 ImVec2 label_pos(top_left.x + (size.x - label_size.x) / 2,
116 top_left.y + (size.y - label_size.y) / 2);
117 draw_list->AddText(label_pos,
118 ThemeColor(WithAlpha(theme.text_primary, 0.82f)), label);
119}
120
123 switch (origin) {
125 return "Oracle default filename";
127 return "Configured filename";
129 return "Configured slot unmapped";
130 }
131 return "Unknown mapping";
132}
133
134} // namespace
135
137 float available_width, float preferred_item_size, float item_spacing,
138 float min_item_size) {
139 const float safe_spacing = std::max(item_spacing, 0.0f);
140 const float usable_width = std::max(available_width, 1.0f);
141 const float safe_min_item_size =
142 std::min(std::max(min_item_size, 1.0f), usable_width);
143 const float clamped_preferred_item_size =
144 std::clamp(preferred_item_size, safe_min_item_size, usable_width);
145
147 layout.columns = std::max(
148 1, static_cast<int>((usable_width + safe_spacing) /
149 (clamped_preferred_item_size + safe_spacing)));
150 layout.item_size = clamped_preferred_item_size;
151 const float total_spacing = safe_spacing * (layout.columns - 1);
152 const float occupied_width =
153 layout.item_size * layout.columns + total_spacing;
154 layout.leading_inset = std::max((usable_width - occupied_width) * 0.5f, 0.0f);
155 return layout;
156}
157
159 float source_height,
160 float box_width,
161 float box_height) {
163 if (source_width <= 0.0f || source_height <= 0.0f || box_width <= 0.0f ||
164 box_height <= 0.0f) {
165 return fit;
166 }
167
168 const float scale =
169 std::min(box_width / source_width, box_height / source_height);
170 fit.valid = true;
171 fit.width = source_width * scale;
172 fit.height = source_height * scale;
173 fit.x = (box_width - fit.width) * 0.5f;
174 fit.y = (box_height - fit.height) * 0.5f;
175 return fit;
176}
177
178bool MatchesDungeonObjectStreamFilter(int object_id, int selected_filter) {
179 switch (selected_filter) {
180 case 1:
181 return object_id >= 0x000 && object_id <= 0x0F7;
182 case 2:
183 return object_id >= 0x100 && object_id <= 0x13F;
184 case 3:
185 return object_id >= 0xF80 && object_id <= 0xFFF;
186 case 0:
187 default:
188 return true;
189 }
190}
191
192bool IsDungeonCustomObjectRuntimeSlot(int object_id, int subtype) {
193 return subtype >= 0 &&
195 object_id);
196}
197
198bool IsMinecartGraphicsRuntimeSlot(int object_id, int subtype) {
199 return object_id == 0x31 &&
200 zelda3::IsMinecartTrackGraphicsSubtype(object_id, subtype);
201}
202
203std::string GetDungeonCustomObjectSlotName(int object_id, int subtype) {
204 static constexpr std::array<const char*, 16> kObject31Names = {
205 "Pit track horizontal", "Pit track vertical",
206 "Pit track corner top-left", "Pit track corner top-right",
207 "Pit track corner bottom-left", "Pit track corner bottom-right",
208 "Floor track vertical", "Floor track horizontal",
209 "Floor track corner top-left", "Floor track corner top-right",
210 "Floor track corner bottom-left", "Floor track corner bottom-right",
211 "Floor track any direction", "Sword House wall object",
212 "Pit track any direction", "Small statue",
213 };
214 static constexpr std::array<const char*, 5> kObject32Names = {
215 "Ice furnace", "Firewood", "Ice chair",
216 "Minish stump", "Minish wall hole",
217 };
218 static constexpr std::array<const char*, 2> kObject54Names = {
219 "Kydreeok body",
220 "Manhandla body",
221 };
222 if (object_id == 0x31 && subtype >= 0 &&
223 subtype < static_cast<int>(kObject31Names.size())) {
224 return kObject31Names[subtype];
225 }
226 if (object_id == 0x32 && subtype >= 0 &&
227 subtype < static_cast<int>(kObject32Names.size())) {
228 return kObject32Names[subtype];
229 }
230 if (object_id == 0x54 && subtype >= 0 &&
231 subtype < static_cast<int>(kObject54Names.size())) {
232 return kObject54Names[subtype];
233 }
234 return "Unknown custom runtime slot";
235}
236
240
242 const auto category = zelda3::ObjectCategories::GetObjectCategory(object_id);
243 return category.ok() && *category == "Chests";
244}
245
247 const auto& theme = AgentUI::GetTheme();
248 const auto category = zelda3::ObjectCategories::GetObjectCategory(object_id);
249 if (!category.ok())
250 return ImGui::GetColorU32(theme.dungeon_object_default);
251 if (*category == "Walls")
252 return ImGui::GetColorU32(theme.dungeon_object_wall);
253 if (*category == "Floors")
254 return ImGui::GetColorU32(theme.dungeon_object_floor);
255 if (*category == "Decorations")
256 return ImGui::GetColorU32(theme.dungeon_object_decoration);
257 if (*category == "Chests")
258 return ImGui::GetColorU32(theme.item_color);
259 if (*category == "Stairs")
260 return ImGui::GetColorU32(theme.selection_primary);
261 if (*category == "Doors")
262 return ImGui::GetColorU32(theme.transport_color);
263 if (*category == "Interactive")
264 return ImGui::GetColorU32(theme.status_success);
265 return ImGui::GetColorU32(theme.music_zone_color);
266}
267
269 const auto category = zelda3::ObjectCategories::GetObjectCategory(object_id);
270 if (!category.ok())
271 return "?";
272 if (*category == "Walls")
273 return "|";
274 if (*category == "Floors")
275 return "_";
276 if (*category == "Decorations")
277 return "~";
278 if (*category == "Chests")
279 return "C";
280 if (*category == "Stairs")
281 return "^";
282 if (*category == "Doors")
283 return "D";
284 if (*category == "Interactive")
285 return "o";
286 return "S";
287}
288
289void DungeonObjectSelector::SelectObject(int obj_id, int subtype) {
290 const int runtime_count =
292 if (runtime_count > 0 && subtype < 0 &&
293 core::FeatureFlags::get().kEnableCustomObjects) {
294 // In custom-enabled projects these IDs are dispatch families, not one
295 // subtype-free object. Selection must come from Custom Assets so the exact
296 // runtime slot and its decoded source asset are known.
297 return;
298 }
299 if (subtype >= 0) {
300 if ((runtime_count > 0 &&
301 !IsDungeonCustomObjectRuntimeSlot(obj_id, subtype)) ||
302 (runtime_count == 0 && subtype >= kPersistedCustomSubtypeSlots)) {
303 return;
304 }
305 if (runtime_count > 0) {
306 if (!core::FeatureFlags::get().kEnableCustomObjects) {
307 return;
308 }
310 if (!GetCustomObjectAssetStatus(obj_id, subtype).ok()) {
311 return;
312 }
313 }
314 }
315
316 selected_object_id_ = obj_id;
317
318 // Create and update preview object
319 uint8_t size = zelda3::DefaultRoomObjectSizeForPlacement(obj_id);
320 if (subtype >= 0) {
321 // Oracle custom objects use the explicitly selected size as a subtype.
322 size =
323 zelda3::CanonicalRoomObjectSize(obj_id, static_cast<uint8_t>(subtype));
324 }
325 preview_object_ = zelda3::RoomObject(obj_id, 0, 0, size, 0);
327 object_loaded_ = true;
332 .ok();
333
334 // Notify callback
337 }
338}
339
342 if (!core::FeatureFlags::get().kEnableCustomObjects) {
343 ImGui::TextDisabled("This project has no custom rail assets.");
344 return false;
345 }
346 const auto layout = ResolveDungeonObjectSelectorGridLayout(
347 ImGui::GetContentRegionAvail().x, 64.0f, ImGui::GetStyle().ItemSpacing.x);
348 bool chosen = false;
349 ImGui::PushID("MinecartRailPalette");
350 // Keep the complete catalog visible, with the same shape order in both
351 // families. These are artwork choices; behavior is edited separately.
352 static constexpr std::array<std::array<int, 7>, 2> kFamilies = {
353 {{7, 6, 8, 9, 10, 11, 12}, {0, 1, 2, 3, 4, 5, 14}}};
354 for (size_t family = 0; family < kFamilies.size(); ++family) {
355 ImGui::TextDisabled("%s",
356 family == 0 ? "Floor-style rails" : "Pit-style rails");
357 int column = 0;
358 for (int subtype : kFamilies[family]) {
359 if (column)
360 ImGui::SameLine();
361 const auto status = GetCustomObjectAssetStatus(0x31, subtype);
362 const auto name = GetDungeonCustomObjectSlotName(0x31, subtype);
363 const auto label = absl::StrFormat("##Rail%d", subtype);
364 ImGui::BeginDisabled(!status.ok());
365 if (ImGui::Selectable(label.c_str(),
367 preview_object_.size_ == subtype,
368 0, ImVec2(layout.item_size, layout.item_size))) {
369 SelectObject(0x31, subtype);
370 chosen = true;
371 }
372 gui::AutoRegisterLastItem("selectable",
373 absl::StrFormat("Rail%d", subtype));
374 if (status.ok()) {
376 }
377 ImGui::EndDisabled();
378 const auto top = ImGui::GetItemRectMin();
379 auto obj = MakePreviewObject(0x31);
380 obj.size_ = subtype;
381 const bool rendered =
382 status.ok() && ImGui::IsItemVisible() &&
383 DrawObjectPreview(obj, ImVec2(top.x + 3, top.y + 3),
384 ImVec2(layout.item_size - 6, layout.item_size - 6));
385 if (!rendered) {
386 ImGui::GetWindowDrawList()->AddText(
387 top, ImGui::GetColorU32(ImGuiCol_TextDisabled),
388 status.ok() ? "Preview..." : "Missing");
389 }
390 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
391 ImGui::BeginTooltip();
392 ImGui::TextUnformatted(name.c_str());
393 ImGui::TextUnformatted(status.ok()
394 ? "Click, then place on the room canvas."
395 : std::string(status.message()).c_str());
396 if (status.ok() && family == 0) {
397 ImGui::TextUnformatted(
398 "Uses fixed floor tiles; check the room's graphics and palette.");
399 }
400 ImGui::EndTooltip();
401 }
402 column = (column + 1) % layout.columns;
403 }
404 }
405 ImGui::PopID();
406 return chosen;
407}
408
410 gui::AutoWidgetScope scope("Dungeon/ObjectBrowser");
412 constexpr const char* categories[] = {
413 "All objects", "Walls", "Floors", "Decorations", "Interactive",
414 "Stairs", "Doors", "Chests", "Special", "Custom assets"};
416 if (browser_state_.category == 9) {
417 if (!core::FeatureFlags::get().kEnableCustomObjects) {
418 ImGui::TextDisabled("Custom objects are disabled");
419 return;
420 }
421 struct Asset {
422 int id;
423 int slot;
424 std::string name;
425 };
426 std::vector<Asset> assets;
428 const int count =
429 std::min(zelda3::CustomObjectManager::Get().GetSubtypeCount(id),
430 kPersistedCustomSubtypeSlots);
431 for (int slot = 0; slot < count; ++slot) {
432 const std::string name = GetDungeonCustomObjectSlotName(id, slot);
433 const std::string key = absl::StrFormat("%02X:%02X", id, slot);
437 std::string::npos)
438 assets.push_back({id, slot, name});
439 }
440 }
441 if (browser_state_.sort == 1)
442 std::stable_sort(assets.begin(), assets.end(),
443 [](const Asset& a, const Asset& b) {
444 return workbench::DungeonBrowserFold(a.name) <
445 workbench::DungeonBrowserFold(b.name);
446 });
447 if (ImGui::BeginChild("##CustomLibrary", ImVec2(0, 0), false)) {
448 for (const auto& asset : assets) {
449 ImGui::PushID(asset.id * 1000 + asset.slot);
450 const auto status = GetCustomObjectAssetStatus(asset.id, asset.slot);
451 ImGui::BeginDisabled(!status.ok());
452 const bool picked = workbench::DrawDungeonBrowserRow(
453 asset.id, asset.name,
454 selected_object_id_ == asset.id &&
455 preview_object_.size_ == asset.slot,
456 [&](ImVec2 pos, ImVec2 size) {
457 auto object = MakePreviewObject(asset.id);
458 object.size_ = zelda3::CanonicalRoomObjectSize(
459 asset.id, static_cast<uint8_t>(asset.slot));
460 return DrawObjectPreview(object, pos, size);
461 },
462 absl::StrFormat("%02X:%02X", asset.id, asset.slot));
463 ImGui::EndDisabled();
464 if (!status.ok() &&
465 ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
466 ImGui::SetTooltip("%s", std::string(status.message()).c_str());
467 if (picked && status.ok())
468 SelectObject(asset.id, asset.slot);
469 DrawObjectContextMenu(asset.id, asset.slot);
470 if (status.ok())
472 static_cast<uint16_t>(asset.id), current_room_id_, 0, 0,
474 asset.id, static_cast<uint8_t>(asset.slot)));
475 ImGui::PopID();
476 }
477 if (assets.empty())
478 ImGui::TextDisabled("No matches");
479 }
480 ImGui::EndChild();
481 return;
482 }
483 std::vector<std::pair<int, std::string>> entries;
484 for (const auto range : {std::pair{0x000, 0x0F7}, std::pair{0x100, 0x13F},
485 std::pair{0xF80, 0xFFF}}) {
486 for (int id = range.first; id <= range.second; ++id) {
489 continue;
490 if (browser_state_.category > 0) {
491 const auto category = zelda3::ObjectCategories::GetObjectCategory(id);
492 if (!category.ok() || *category != categories[browser_state_.category])
493 continue;
494 }
495 std::string name = zelda3::GetObjectName(id);
497 entries.emplace_back(id, std::move(name));
498 }
499 }
500 if (browser_state_.sort == 1)
501 std::stable_sort(entries.begin(), entries.end(),
502 [](const auto& a, const auto& b) {
503 return workbench::DungeonBrowserFold(a.second) <
504 workbench::DungeonBrowserFold(b.second);
505 });
506 if (ImGui::BeginChild("##ObjectLibrary", ImVec2(0, 0), false)) {
507 for (const auto& [id, name] : entries) {
508 ImGui::PushID(id);
510 id, name, selected_object_id_ == id,
511 [&, id = id](ImVec2 pos, ImVec2 size) {
512 return DrawObjectPreview(MakePreviewObject(id), pos, size);
513 }))
514 SelectObject(id);
515 gui::AutoRegisterLastItem("selectable",
516 absl::StrFormat("place_object_%03X", id));
519 static_cast<uint16_t>(id), current_room_id_, 0, 0,
521 ImGui::PopID();
522 }
523 if (entries.empty())
524 ImGui::TextDisabled("No matches");
525 }
526 ImGui::EndChild();
527}
528
529void DungeonObjectSelector::DrawObjectAssetBrowser(float minimum_grid_height) {
532 ImGui::TextWrapped("%s", custom_object_action_error_.c_str());
533 }
534 const auto& theme = AgentUI::GetTheme();
535
536 // Object ranges supported by the room-object stream codec.
537 struct ObjectRange {
538 int start;
539 int end;
540 const char* label;
541 };
542 static const ObjectRange ranges[] = {
543 {0x00, 0xF7, "Type 1"},
544 {0x100, 0x13F, "Type 2"},
545 {0xF80, 0xFFF, "Type 3"},
546 };
547
549 auto& obj_manager = zelda3::CustomObjectManager::Get();
550 int custom_count = 0;
551 for (const int object_id : zelda3::CustomObjectManager::RuntimeObjectIds()) {
552 custom_count += std::min(obj_manager.GetSubtypeCount(object_id),
553 kPersistedCustomSubtypeSlots);
554 }
555
556 const std::string custom_mode_label =
557 absl::StrFormat("Custom Assets (%d)", custom_count);
558 if (ImGui::BeginTable(
559 "##ObjectBrowserMode", 2,
560 ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX)) {
561 ImGui::TableNextColumn();
562 if (ImGui::Selectable(tr("Objects"), browser_mode_ == BrowserMode::kObjects,
563 0, ImVec2(ImGui::GetContentRegionAvail().x, 0.0f))) {
565 }
566 ImGui::TableNextColumn();
567 if (ImGui::Selectable(custom_mode_label.c_str(),
569 ImVec2(ImGui::GetContentRegionAvail().x, 0.0f))) {
571 }
572 ImGui::EndTable();
573 }
574
577 return;
578 }
579 ImGui::Spacing();
580
581 const ImGuiStyle& style = ImGui::GetStyle();
582 const float control_spacing = std::max(2.0f, style.ItemSpacing.x * 0.5f);
583 {
584 gui::StyleVarGuard toolbar_spacing_guard(
585 ImGuiStyleVar_ItemSpacing,
586 ImVec2(control_spacing, std::max(2.0f, style.ItemSpacing.y * 0.5f)));
587
588 const float toolbar_width =
589 std::max(ImGui::GetContentRegionAvail().x, 1.0f);
590 const float button_h = ImGui::GetFrameHeight();
591 const bool has_search = object_search_buffer_[0] != '\0';
592 const float size_btn_w = button_h;
593 const float more_btn_w = button_h;
594 const float clear_btn_w = has_search ? button_h : 0.0f;
595 const float search_w =
596 std::max(60.0f, toolbar_width - size_btn_w - more_btn_w - clear_btn_w -
597 control_spacing * (has_search ? 3.0f : 2.0f));
598
599 ImGui::SetNextItemWidth(search_w);
600 ImGui::InputTextWithHint("##ObjectSearch", "Search by name or hex ID...",
602 sizeof(object_search_buffer_));
603 if (has_search) {
604 ImGui::SameLine(0.0f, control_spacing);
605 if (ImGui::Button(ICON_MD_CLOSE "##ClearSearch",
606 ImVec2(clear_btn_w, 0.0f))) {
607 object_search_buffer_[0] = '\0';
608 }
609 if (ImGui::IsItemHovered()) {
610 ImGui::SetTooltip(tr("Clear search"));
611 }
612 }
613 ImGui::SameLine(0.0f, control_spacing);
614 const char* size_icon = object_grid_density_ == 0
615 ? "S"
616 : (object_grid_density_ == 2 ? "L" : "M");
617 if (ImGui::Button(size_icon, ImVec2(size_btn_w, 0.0f))) {
619 }
620 if (ImGui::IsItemHovered()) {
621 ImGui::SetTooltip(tr("Card size: %s (click to cycle)"),
622 kObjectGridDensityLabels[object_grid_density_]);
623 }
624 ImGui::SameLine(0.0f, control_spacing);
625 if (ImGui::Button(ICON_MD_TUNE "##ObjectSelectorMore",
626 ImVec2(more_btn_w, 0.0f))) {
627 ImGui::OpenPopup("##ObjectSelectorOptionsPopup");
628 }
629 if (ImGui::IsItemHovered()) {
630 ImGui::SetTooltip(tr("Object selector options"));
631 }
632
633 static const char* kFilterLabels[] = {
634 "All categories", "Walls", "Floors", "Chests",
635 "Doorways", "Decor", "Stairs"};
636 static const char* kStreamLabels[] = {"All streams", "Type 1", "Type 2",
637 "Type 3"};
638
639 const float used_width = ImGui::GetFrameHeight() +
640 ImGui::CalcTextSize("Used here").x +
641 style.ItemInnerSpacing.x;
642 const float category_width =
643 std::max(1.0f, toolbar_width - used_width - control_spacing);
644
645 ImGui::SetNextItemWidth(category_width);
646 ImGui::Combo("##ObjectFilterType", &object_type_filter_, kFilterLabels,
647 IM_ARRAYSIZE(kFilterLabels));
648 if (object_type_filter_ == 4 && ImGui::IsItemHovered()) {
649 ImGui::SetTooltip(
650 tr("Doorways are tile objects. Use the Door Editor "
651 "to place or change room doors."));
652 }
653 ImGui::SameLine(0.0f, control_spacing);
654 ImGui::Checkbox("Used here", &used_here_only_);
655 if (ImGui::BeginPopup("##ObjectSelectorOptionsPopup")) {
656 const bool has_filters = object_search_buffer_[0] != '\0' ||
657 object_type_filter_ != 0 ||
659 if (ImGui::MenuItem(tr("Clear filters"), nullptr, false, has_filters)) {
660 object_search_buffer_[0] = '\0';
663 used_here_only_ = false;
664 }
665
666 ImGui::SetNextItemWidth(130.0f);
667 ImGui::Combo("Object stream", &object_stream_filter_, kStreamLabels,
668 IM_ARRAYSIZE(kStreamLabels));
669
670 ImGui::SeparatorText(tr("Display"));
671 ImGui::Checkbox(tr("Show thumbnails"), &enable_object_previews_);
672 if (ImGui::IsItemHovered()) {
673 ImGui::SetTooltip(
674 tr("Show rendered object thumbnails in the selector.\n"
675 "Requires a room to be loaded and may cost some performance."));
676 }
677 ImGui::SeparatorText(tr("Card size"));
678 for (int density = 0; density < 3; ++density) {
679 if (ImGui::MenuItem(kObjectGridDensityLabels[density], nullptr,
680 object_grid_density_ == density)) {
681 object_grid_density_ = density;
682 }
683 }
684
685 ImGui::EndPopup();
686 }
687 }
688
689 const auto name_strip = ReserveBrowserNameStrip();
690 std::string browser_name =
692 ? absl::StrFormat("0x%03X %s", selected_object_id_,
694 : tr("Point to an object; right-click for details");
695
696 // The grid is the selector's sole scroll owner. Calculate its geometry only
697 // after entering the child so themed padding and the scrollbar are included.
698 const float child_height = std::max(ImGui::GetContentRegionAvail().y,
699 std::max(minimum_grid_height, 1.0f));
700 if (ImGui::BeginChild("##ObjectGrid", ImVec2(0, child_height), false)) {
701 const float item_spacing = control_spacing;
702 // GetContentRegionAvail() already excludes the child window's scrollbar
703 // and padding. Centering the fixed-density grid balances any remainder so
704 // a resize cannot leave a second, artificial gutter on the right.
705 const auto grid_layout = ResolveDungeonObjectSelectorGridLayout(
706 ImGui::GetContentRegionAvail().x,
707 GetObjectGridItemSize(object_grid_density_), item_spacing);
708 const float item_size = grid_layout.item_size;
709 const int columns = grid_layout.columns;
710 gui::StyleVarGuard grid_spacing_guard(
711 ImGuiStyleVar_ItemSpacing,
712 ImVec2(item_spacing, std::max(item_spacing, style.ItemSpacing.y)));
713
714 // Iterate through all object ranges
715 for (const auto& range : ranges) {
716 if (!MatchesDungeonObjectStreamFilter(range.start,
718 continue;
719 }
720
721 if (object_stream_filter_ == 0) {
722 ImGui::TextDisabled("%s 0x%03X-0x%03X", range.label, range.start,
723 range.end);
724 }
725
726 int current_column = 0;
727
728 for (int obj_id = range.start; obj_id <= range.end; ++obj_id) {
731 0) {
732 // Custom-enabled runtime families are shown only in Custom Assets,
733 // where an exact subtype and validated source asset are required.
734 continue;
735 }
737 continue;
738 }
739 if (!MatchesRoomUsage(obj_id))
740 continue;
742 continue;
743 }
744
745 std::string full_name = zelda3::GetObjectName(obj_id);
746 if (!MatchesObjectSearch(obj_id, full_name)) {
747 continue;
748 }
749
750 if (current_column > 0) {
751 ImGui::SameLine(0.0f, item_spacing);
752 } else {
753 ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
754 grid_layout.leading_inset);
755 }
756
757 ImGui::PushID(obj_id);
758
759 // Create selectable button for object
760 bool is_selected = (selected_object_id_ == obj_id);
761 ImVec2 button_size(item_size, item_size);
762
763 if (ImGui::Selectable("", is_selected, 0, button_size)) {
764 SelectObject(obj_id);
765 }
766 const bool item_visible = ImGui::IsItemVisible();
767 // Routine symbology — one fast registry lookup per visible card only.
768 // Off-screen items are skipped entirely (no work queued until culled).
770 if (item_visible) {
771 sym = zelda3::GetSymbologyForObject(static_cast<int16_t>(obj_id));
772 }
773 ImVec2 button_pos = ImGui::GetItemRectMin();
774 DrawObjectContextMenu(obj_id);
776 static_cast<uint16_t>(obj_id), current_room_id_, 0, 0,
778 ImDrawList* draw_list = ImGui::GetWindowDrawList();
779 const bool show_id = item_size >= 32.0f;
780 const float footer_height =
781 show_id ? std::min(item_size * 0.32f, ImGui::GetFontSize() + 3.0f)
782 : 0.0f;
783 const float card_padding = std::min(3.0f, item_size * 0.08f);
784 const ImVec2 preview_pos(button_pos.x + card_padding,
785 button_pos.y + card_padding);
786 const ImVec2 preview_box(
787 std::max(1.0f, item_size - card_padding * 2.0f),
788 std::max(1.0f, item_size - footer_height - card_padding * 2.0f));
789
790 // Only attempt graphical preview if enabled (performance optimization)
791 bool rendered = false;
792 if (item_visible && enable_object_previews_) {
793 rendered = DrawObjectPreview(MakePreviewObject(obj_id), preview_pos,
794 preview_box);
795 }
796
797 if (item_visible && !rendered) {
798 std::string symbol = GetObjectTypeSymbol(obj_id);
799 DrawFallbackPreviewTile(
800 draw_list, preview_pos, preview_box,
801 ImGui::ColorConvertU32ToFloat4(GetObjectTypeColor(obj_id)),
802 symbol.c_str());
803 }
804
805 const bool item_hovered = ImGui::IsItemHovered();
806 if (item_visible && (is_selected || item_hovered)) {
807 const ImU32 border_color =
808 ImGui::GetColorU32(is_selected ? theme.dungeon_selection_primary
809 : theme.panel_border_color);
810 draw_list->AddRect(
811 button_pos,
812 ImVec2(button_pos.x + item_size, button_pos.y + item_size),
813 border_color, 2.0f, is_selected ? 2.0f : 1.0f);
814 }
815
816 // Compact routine symbology badge — top-right corner overlay, drawn
817 // over both graphical thumbnails and fallback tiles. Only rendered
818 // for visible cards (sym is only populated when item_visible).
819 // Skipped for unmapped objects (badge == "?") to reduce visual noise.
820 if (item_visible && !sym.badge.empty() && sym.badge != "?") {
821 const ImVec2 badge_text_size =
822 ImGui::CalcTextSize(sym.badge.c_str(), nullptr, true);
823 const float badge_pad = 2.0f;
824 const float badge_w = badge_text_size.x + badge_pad * 2.0f;
825 const float badge_h = badge_text_size.y + badge_pad;
826 const ImVec2 badge_tl(button_pos.x + item_size - badge_w,
827 button_pos.y);
828 const ImVec2 badge_br(button_pos.x + item_size,
829 button_pos.y + badge_h);
830 draw_list->AddRectFilled(
831 badge_tl, badge_br,
832 ImGui::GetColorU32(WithAlpha(theme.panel_bg_darker, 0.82f)),
833 1.5f);
834 // BothBG routines use the wall-accent color; others use secondary
835 // text so the badge never competes with the thumbnail content.
836 const ImVec4& badge_color = sym.dual_layer
837 ? theme.dungeon_object_wall
838 : theme.text_secondary_gray;
839 draw_list->AddText(
840 ImVec2(badge_tl.x + badge_pad, badge_tl.y + badge_pad * 0.5f),
841 ImGui::GetColorU32(badge_color), sym.badge.c_str());
842 }
843
844 if (item_visible && show_id) {
845 const ImVec2 card_bottom(button_pos.x + item_size,
846 button_pos.y + item_size);
847 const float footer_y = card_bottom.y - footer_height;
848 draw_list->AddRectFilled(
849 ImVec2(button_pos.x, footer_y), card_bottom,
850 ImGui::GetColorU32(WithAlpha(theme.panel_bg_darker, 0.88f)),
851 2.0f);
852 std::string id_text = absl::StrFormat("%03X", obj_id);
853 ImVec2 id_size = ImGui::CalcTextSize(id_text.c_str());
854 ImVec2 id_pos = ImVec2(button_pos.x + (item_size - id_size.x) / 2,
855 footer_y + (footer_height - id_size.y) * 0.5f);
856 draw_list->AddText(id_pos, ImGui::GetColorU32(theme.text_primary),
857 id_text.c_str());
858 }
859
860 if (item_hovered) {
861 browser_name = absl::StrFormat("0x%03X %s", obj_id, full_name);
862 }
863
864 ImGui::PopID();
865
866 current_column = (current_column + 1) % columns;
867 } // end object loop
868 } // end range loop
869 }
870
871 ImGui::EndChild();
872 DrawBrowserNameStrip(name_strip, browser_name);
873}
874
875void DungeonObjectSelector::DrawObjectContextMenu(int object_id, int subtype) {
876 if (!ImGui::BeginPopupContextItem("##ObjectActions"))
877 return;
878 const bool supported =
879 subtype >= 0
880 ? GetCustomObjectAssetStatus(object_id, subtype).ok()
882 const bool has_context = rooms_ && rooms_->GetIfLoaded(current_room_id_);
883 const bool can_edit =
884 supported && has_context &&
885 (subtype >= 0 ? tile_editor_panel_ != nullptr
886 : static_cast<bool>(edit_object_tiles_callback_));
887 if (ImGui::MenuItem("Edit tiles...", nullptr, false, can_edit)) {
888 const auto status =
889 subtype >= 0 ? OpenExistingCustomObjectEditor(object_id, subtype,
894 status.ok() ? "" : std::string(status.message());
895 }
896 if (!supported && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
897 ImGui::SetTooltip(
898 "Tile editing is available for straight walls, 4x4 corners, "
899 "fixed 2x2 objects and custom assets. This layout is not supported "
900 "yet.");
901 }
902 ImGui::Separator();
903 if (ImGui::BeginMenu("Details")) {
904 DrawObjectDetails(object_id, subtype);
905 ImGui::EndMenu();
906 }
907 ImGui::EndPopup();
908}
909
910void DungeonObjectSelector::DrawObjectDetails(int object_id, int subtype) {
911 const auto& theme = AgentUI::GetTheme();
912 ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 260.0f);
913 if (subtype >= 0) {
914 auto& manager = zelda3::CustomObjectManager::Get();
915 const auto binding = manager.ResolveSlotBinding(object_id, subtype);
916 ImGui::TextColored(theme.selection_primary, "Custom 0x%02X:%02X", object_id,
917 subtype);
918 ImGui::TextWrapped(
919 "%s", GetDungeonCustomObjectSlotName(object_id, subtype).c_str());
920 ImGui::Separator();
921 ImGui::TextWrapped("File: %s", binding.ok() && !binding->filename.empty()
922 ? binding->filename.c_str()
923 : "(unmapped)");
924 if (binding.ok()) {
925 ImGui::TextWrapped("Mapping: %s",
926 CustomObjectMappingOriginLabel(binding->origin));
927 }
928 const auto status = GetCustomObjectAssetStatus(object_id, subtype);
929 ImGui::TextColored(status.ok() ? theme.status_success : theme.status_error,
930 "%s",
931 status.ok() ? "Asset decoded and ready"
932 : std::string(status.message()).c_str());
933 } else {
934 ImGui::TextColored(theme.selection_primary, "Object 0x%03X", object_id);
935 ImGui::TextWrapped("%s", zelda3::GetObjectName(object_id).c_str());
936 const auto sym =
937 zelda3::GetSymbologyForObject(static_cast<int16_t>(object_id));
938 ImGui::Text("Subtype %d", zelda3::GetObjectSubtype(object_id));
939 if (!sym.family.empty()) {
940 ImGui::TextWrapped("Routine: %s", sym.family.c_str());
941 }
942 if (sym.dual_layer) {
943 ImGui::TextColored(theme.dungeon_object_wall,
944 "BothBG: writes to BG1 and BG2");
945 }
946 const auto* room = rooms_ ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
947 if (rom_ && room) {
948 const uint8_t size = zelda3::DefaultRoomObjectSizeForPlacement(object_id);
949 const uint32_t key = MakeLayoutCacheKey(object_id, size, room);
950 auto layout = layout_cache_.find(key);
951 if (layout == layout_cache_.end()) {
953 auto captured = editor.CaptureObjectLayout(
954 object_id, *room, current_palette_group_, size);
955 if (captured.ok()) {
956 layout = layout_cache_.emplace(key, std::move(*captured)).first;
957 }
958 }
959 if (layout != layout_cache_.end()) {
960 const auto& value = layout->second;
961 ImGui::Text("Tiles: %zu", value.cells.size());
962 if (value.bounds_width > 0 && value.bounds_height > 0) {
963 // Large floor/wall objects must not expand the details past the menu.
964 const float scale = std::min({4.0f, 160.0f / value.bounds_width,
965 120.0f / value.bounds_height});
966 const ImVec2 origin = ImGui::GetCursorScreenPos();
967 const ImVec2 size(value.bounds_width * scale,
968 value.bounds_height * scale);
969 auto* draw = ImGui::GetWindowDrawList();
970 draw->PushClipRect(
971 origin, ImVec2(origin.x + size.x, origin.y + size.y), true);
972 for (const auto& cell : value.cells) {
973 const ImVec2 start(origin.x + cell.rel_x * scale,
974 origin.y + cell.rel_y * scale);
975 const ImVec2 end(start.x + scale, start.y + scale);
976 draw->AddRectFilled(start, end,
977 ThemeColor(theme.dungeon_grid_cell_highlight));
978 draw->AddRect(start, end, ThemeColor(theme.panel_border_color));
979 }
980 draw->PopClipRect();
981 ImGui::Dummy(size);
982 }
983 }
984 }
985 }
986 ImGui::PopTextWrapPos();
987}
988
990 if (!used_here_only_)
991 return true;
992 const auto* room = rooms_ ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
993 if (!room)
994 return false;
995 const auto& objects = room->GetTileObjects();
996 return std::any_of(
997 objects.begin(), objects.end(),
998 [obj_id](const auto& object) { return object.id_ == obj_id; });
999}
1000
1001bool DungeonObjectSelector::MatchesObjectFilter(int obj_id, int filter_type) {
1002 constexpr std::array<const char*, 6> kCategories = {
1003 "Walls", "Floors", "Chests", "Doors", "Decorations", "Stairs"};
1004 if (filter_type < 1 || filter_type > static_cast<int>(kCategories.size())) {
1005 return true;
1006 }
1007 const auto category = zelda3::ObjectCategories::GetObjectCategory(obj_id);
1008 return category.ok() && *category == kCategories[filter_type - 1];
1009}
1010
1012 const std::string& name,
1013 int subtype) const {
1014 if (object_search_buffer_[0] == '\0') {
1015 return true;
1016 }
1017
1018 auto to_lower = [](std::string value) {
1019 std::transform(value.begin(), value.end(), value.begin(),
1020 [](unsigned char c) { return std::tolower(c); });
1021 return value;
1022 };
1023
1024 std::string needle = to_lower(object_search_buffer_);
1025 std::string name_lower = to_lower(name);
1026
1027 std::string id_hex = absl::StrFormat("%03X", obj_id);
1028 std::string id_lower = to_lower(id_hex);
1029 std::string id_pref = "0x" + id_lower;
1030
1031 if (name_lower.find(needle) != std::string::npos) {
1032 return true;
1033 }
1034 if (id_lower.find(needle) != std::string::npos ||
1035 id_pref.find(needle) != std::string::npos) {
1036 return true;
1037 }
1038
1039 if (subtype >= 0) {
1040 std::string sub_hex = absl::StrFormat("%02X", subtype);
1041 std::string sub_lower = to_lower(sub_hex);
1042 std::string combined = id_lower + ":" + sub_lower;
1043 std::string combined_pref = "0x" + combined;
1044 if (combined.find(needle) != std::string::npos ||
1045 combined_pref.find(needle) != std::string::npos) {
1046 return true;
1047 }
1048 }
1049
1050 return false;
1051}
1052
1054 const zelda3::RoomObject& object, int& width, int& height) {
1055 auto [w, h] = zelda3::DimensionService::Get().GetPixelDimensions(object);
1056 width = std::min(w, 256);
1057 height = std::min(h, 256);
1058}
1059
1066
1068 const bool custom_objects_enabled =
1070 const uint64_t generation =
1072 const bool custom_feature_changed =
1073 custom_objects_enabled != observed_custom_objects_enabled_;
1074 if (generation == observed_custom_object_generation_ &&
1075 !custom_feature_changed) {
1076 return;
1077 }
1078
1079 if (rooms_ != nullptr) {
1081 [](int, zelda3::Room& room) { room.MarkObjectsDirty(); });
1082 }
1085 observed_custom_objects_enabled_ = custom_objects_enabled;
1086
1087 const int object_id = preview_object_.id_;
1088 const int subtype = preview_object_.size_ & 0x1F;
1089 const bool fixed_runtime_slot =
1090 IsDungeonCustomObjectRuntimeSlot(object_id, subtype);
1091 auto& custom_object_manager = zelda3::CustomObjectManager::Get();
1092 const bool exact_override_mapped_now =
1093 !fixed_runtime_slot &&
1094 !custom_object_manager.ResolveFilename(object_id, subtype).empty();
1095 if (!object_loaded_ ||
1096 (!fixed_runtime_slot && !preview_uses_custom_override_ &&
1097 !exact_override_mapped_now)) {
1098 return;
1099 }
1100
1101 // A subtype-free vanilla selection for a custom runtime family stores an
1102 // ordinary size in the
1103 // same bits used by Oracle's custom dispatch. Never reinterpret that queued
1104 // placement across either feature-state edge; require a fresh selection.
1105 if (fixed_runtime_slot && custom_feature_changed) {
1106 object_loaded_ = false;
1111 }
1112 return;
1113 }
1114
1115 // With the feature stably disabled, these object IDs retain vanilla size
1116 // semantics. A custom-asset cache or mapping change is irrelevant to that
1117 // queued placement.
1118 if (fixed_runtime_slot && !custom_objects_enabled) {
1119 return;
1120 }
1121
1122 if (fixed_runtime_slot &&
1123 !GetCustomObjectAssetStatus(object_id, subtype).ok()) {
1124 object_loaded_ = false;
1129 }
1130 return;
1131 }
1132
1133 if (!fixed_runtime_slot) {
1135 custom_objects_enabled &&
1136 custom_object_manager.GetObjectInternal(object_id, subtype).ok();
1137 }
1138
1141 }
1142}
1143
1163
1165 int subtype) {
1166 if (!IsDungeonCustomObjectRuntimeSlot(object_id, subtype)) {
1167 return absl::OutOfRangeError(
1168 "Custom object subtype is outside the runtime dispatch table");
1169 }
1170
1171 const uint32_t key =
1172 (static_cast<uint32_t>(object_id) << 8) | static_cast<uint32_t>(subtype);
1173 if (const auto cached = custom_asset_status_cache_.find(key);
1174 cached != custom_asset_status_cache_.end()) {
1175 return cached->second;
1176 }
1177
1178 auto object_or =
1180 absl::Status status = object_or.ok() ? absl::OkStatus() : object_or.status();
1181 custom_asset_status_cache_.emplace(key, status);
1182 return status;
1183}
1184
1186 zelda3::RoomObject obj(obj_id, 0, 0,
1188 obj.SetRom(rom_);
1189 return obj;
1190}
1191
1198
1200 auto& arena = gfx::Arena::Get();
1201 for (auto& [key, preview] : preview_cache_) {
1202 (void)key;
1203 if (preview != nullptr) {
1204 arena.RetireBitmap(preview->bitmap());
1205 }
1206 }
1207 preview_cache_.clear();
1208 failed_preview_keys_.clear();
1209}
1210
1232
1234 uint8_t preview_size,
1235 const zelda3::Room* room) {
1236 uint8_t room_floor = 0;
1237 if (room != nullptr) {
1238 if (object_id == 0xC4) {
1239 room_floor = room->floor1() & 0x0F;
1240 } else if (object_id == 0xDB) {
1241 room_floor = room->floor2() & 0x0F;
1242 }
1243 }
1244
1245 return (static_cast<uint32_t>(object_id) << 16) |
1246 (static_cast<uint32_t>(preview_size) << 8) | room_floor;
1247}
1248
1250 gfx::BackgroundBuffer** out) {
1251 if (out == nullptr) {
1252 return false;
1253 }
1254 *out = nullptr;
1255 if (!rom_ || !rom_->is_loaded()) {
1256 return false;
1257 }
1259
1260 // Check if room context changed - invalidate cache if so
1261 const zelda3::Room* room =
1262 rooms_ != nullptr ? rooms_->GetIfLoaded(current_room_id_) : nullptr;
1263 if (room == nullptr) {
1264 return false;
1265 }
1267
1268 // Check if already in cache
1269 // Key: object, subtype, blockset, palette, and both room floor nibbles.
1270 // Room/entrance changes clear the complete cache before this lookup.
1271 const uint8_t preview_size =
1272 zelda3::CanonicalRoomObjectSize(object.id_, object.size());
1273 int subtype = preview_size & 0x1F;
1274 uint64_t cache_key =
1275 (static_cast<uint64_t>(object.id_) << 32) |
1276 (static_cast<uint64_t>(subtype) << 24) |
1277 (static_cast<uint64_t>(cached_preview_blockset_) << 16) |
1278 (static_cast<uint64_t>(cached_preview_palette_) << 8) |
1279 (static_cast<uint64_t>(cached_preview_floor1_ & 0x0F) << 4) |
1280 static_cast<uint64_t>(cached_preview_floor2_ & 0x0F);
1281
1282 auto it = preview_cache_.find(cache_key);
1283 if (it != preview_cache_.end()) {
1284 *out = it->second.get();
1285 return (*out)->bitmap().texture() != nullptr;
1286 }
1287 // Objects whose layout cannot be captured or rendered stay failed until the
1288 // cache is invalidated; without this every visible card rebuilt an
1289 // ObjectDrawer and two 512x512 buffers each frame.
1290 if (failed_preview_keys_.count(cache_key) != 0) {
1291 return false;
1292 }
1293
1294 // Create new preview using ObjectTileEditor
1295 const uint8_t* gfx_data = room->get_gfx_buffer().data();
1296
1298 auto layout_or = editor.CaptureObjectLayout(
1299 object.id_, *room, current_palette_group_, preview_size);
1300 if (!layout_or.ok()) {
1301 failed_preview_keys_.insert(cache_key);
1302 return false;
1303 }
1304 const auto& layout = layout_or.value();
1305
1306 // Create preview buffer large enough for object
1307 int bmp_w = std::max(8, layout.bounds_width * 8);
1308 int bmp_h = std::max(8, layout.bounds_height * 8);
1309 auto preview = std::make_unique<gfx::BackgroundBuffer>(bmp_w, bmp_h);
1310 preview->EnsureBitmapInitialized();
1311
1312 // Render layout to bitmap
1313 auto render_status = editor.RenderLayoutToBitmap(
1314 layout, preview->bitmap(), gfx_data, current_palette_group_);
1315 if (!render_status.ok()) {
1316 gfx::Arena::Get().RetireBitmap(preview->bitmap());
1317 failed_preview_keys_.insert(cache_key);
1318 return false;
1319 }
1320
1321 auto& bitmap = preview->bitmap();
1322 // Texture creation and SDL sync
1323 if (!bitmap.surface()) {
1324 gfx::Arena::Get().RetireBitmap(bitmap);
1325 failed_preview_keys_.insert(cache_key);
1326 return false;
1327 }
1328 SDL_LockSurface(bitmap.surface());
1329 memcpy(bitmap.surface()->pixels, bitmap.mutable_data().data(),
1330 bitmap.mutable_data().size());
1331 SDL_UnlockSurface(bitmap.surface());
1332
1333 // Install the owner before queuing CREATE. The renderer may defer this
1334 // command until DoRender, so the Bitmap address must remain valid even when
1335 // this frame falls back to the symbolic preview.
1336 auto [cache_it, inserted] =
1337 preview_cache_.try_emplace(cache_key, std::move(preview));
1338 if (!inserted) {
1339 if (preview != nullptr) {
1340 gfx::Arena::Get().RetireBitmap(preview->bitmap());
1341 }
1342 *out = cache_it->second.get();
1343 return (*out)->bitmap().texture() != nullptr;
1344 }
1345
1346 *out = cache_it->second.get();
1347 auto& cached_bitmap = (*out)->bitmap();
1349 &cached_bitmap);
1351
1352 // A null texture is an expected deferred state when the Arena has no active
1353 // renderer yet. Keep the cache entry and its queued owner alive; the next
1354 // frame will draw it after DoRender processes CREATE.
1355 return cached_bitmap.texture() != nullptr;
1356}
1357
1359 ImVec2 top_left,
1360 ImVec2 box_size) {
1361 gfx::BackgroundBuffer* preview = nullptr;
1362 if (!GetOrCreatePreview(object, &preview)) {
1363 return false;
1364 }
1365
1366 // Draw the cached preview image
1367 auto& bitmap = preview->bitmap();
1368 if (!bitmap.texture()) {
1369 return false;
1370 }
1371
1373 static_cast<float>(bitmap.width()), static_cast<float>(bitmap.height()),
1374 box_size.x, box_size.y);
1375 if (!fit.valid) {
1376 return false;
1377 }
1378
1379 const ImVec2 image_top_left(top_left.x + fit.x, top_left.y + fit.y);
1380 const ImVec2 image_bottom_right(image_top_left.x + fit.width,
1381 image_top_left.y + fit.height);
1382 ImGui::GetWindowDrawList()->AddImage((ImTextureID)(intptr_t)bitmap.texture(),
1383 image_top_left, image_bottom_right,
1384 ImVec2(0, 0), ImVec2(1, 1),
1385 ImGui::GetColorU32(ImVec4(1, 1, 1, 1)));
1386 return true;
1387}
1388
1390 int16_t object_id, int subtype, int room_id) {
1391 if (tile_editor_panel_ == nullptr) {
1392 return absl::FailedPreconditionError(
1393 "Object Tile Editor panel is unavailable");
1394 }
1395
1396 const absl::Status open_status = tile_editor_panel_->OpenForCustomObject(
1397 object_id, subtype, room_id, rooms_, current_palette_group_);
1398 if (!open_status.ok()) {
1401 }
1402 return open_status;
1403 }
1407 return absl::NotFoundError(
1408 "Object Tile Editor window is not registered in this session");
1409 }
1410 return absl::OkStatus();
1411}
1412
1414 const auto& theme = AgentUI::GetTheme();
1415 auto& obj_manager = zelda3::CustomObjectManager::Get();
1416 const std::string custom_base_path = obj_manager.GetBasePath();
1417
1418 ImGui::TextColored(theme.text_info,
1419 ICON_MD_PRECISION_MANUFACTURING " Custom Assets");
1420 ImGui::PushTextWrapPos(0.0f);
1421 ImGui::TextColored(
1422 theme.text_secondary_gray,
1423 tr("Manage the 21 fixed runtime assets used by Oracle custom objects."));
1424 ImGui::TextDisabled(
1425 "%s", tr("New subtypes require an ASM dispatch-table change; this "
1426 "browser edits or places existing slots only."));
1427 ImGui::PopTextWrapPos();
1428 ImGui::Separator();
1429
1430 if (ImGui::BeginTable(
1431 "##CustomObjectToolbar", 2,
1432 ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_NoPadOuterX)) {
1433 ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 2.0f);
1434 ImGui::TableSetupColumn("Actions", ImGuiTableColumnFlags_WidthFixed,
1435 180.0f);
1436 ImGui::TableNextRow();
1437
1438 ImGui::TableNextColumn();
1439 if (custom_base_path.empty()) {
1440 ImGui::TextColored(theme.text_warning_yellow, ICON_MD_WARNING
1441 " Custom object folder is not configured.");
1442 } else {
1443 ImGui::TextColored(theme.text_secondary_gray,
1444 ICON_MD_FOLDER " Asset folder");
1445 if (ImGui::IsItemHovered()) {
1446 ImGui::SetTooltip("%s", custom_base_path.c_str());
1447 }
1448 }
1449
1450 ImGui::TableNextColumn();
1451 if (ImGui::Button(ICON_MD_REFRESH " Reload Assets", ImVec2(-1, 0))) {
1452 obj_manager.ReloadAll();
1455 }
1456 if (ImGui::IsItemHovered()) {
1457 ImGui::SetTooltip(
1458 tr("Refresh custom object and external sprite previews from disk. "
1459 "Unsaved room and tile edits are kept."));
1460 }
1461 ImGui::EndTable();
1462 }
1463
1464 if (!core::FeatureFlags::get().kEnableCustomObjects) {
1465 ImGui::TextColored(
1466 theme.text_warning_yellow, ICON_MD_WARNING
1467 " Custom Objects is disabled for this project. Enable the feature "
1468 "before editing or placing runtime assets.");
1469 return;
1470 }
1471
1472 ImGui::TextDisabled("%s", tr("Card size"));
1473 ImGui::SameLine();
1474 ImGui::SetNextItemWidth(-1.0f);
1475 ImGui::Combo("##CustomAssetCardSize", &object_grid_density_,
1476 kObjectGridDensityLabels,
1477 IM_ARRAYSIZE(kObjectGridDensityLabels));
1478
1479 ImGui::PushTextWrapPos(0.0f);
1480 ImGui::TextColored(
1481 theme.text_secondary_gray, ICON_MD_INFO
1482 " Track tile layouts are assets here; routes and collision are managed "
1483 "in Minecart Tracks.");
1484 ImGui::PopTextWrapPos();
1485 ImGui::Spacing();
1486
1487 struct CustomAssetFamily {
1488 int object_id;
1489 const char* label;
1490 const char* description;
1491 };
1492 static constexpr std::array<CustomAssetFamily, 3> kFamilies = {{
1493 {0x31, "Tracks + Props (16)",
1494 "Minecart tile layouts, the Sword House wall object, and a small "
1495 "statue"},
1496 {0x32, "Ice + Minish Props (5)",
1497 "Furnace, firewood, chair, Minish stump, and Minish wall hole assets"},
1498 {0x54, "Boss Bodies (2)", "Kydreeok and Manhandla body tilemaps"},
1499 }};
1500 const CustomAssetFamily* selected_family = &kFamilies.front();
1501 for (const auto& family : kFamilies) {
1502 if (custom_asset_family_id_ == family.object_id) {
1503 selected_family = &family;
1504 break;
1505 }
1506 }
1507 ImGui::TextDisabled("%s", tr("Asset family"));
1508 ImGui::SetNextItemWidth(-1.0f);
1509 if (ImGui::BeginCombo("##CustomAssetFamily", selected_family->label)) {
1510 for (const auto& family : kFamilies) {
1511 const bool selected = custom_asset_family_id_ == family.object_id;
1512 if (ImGui::Selectable(family.label, selected)) {
1513 custom_asset_family_id_ = family.object_id;
1515 selected_family = &family;
1517 }
1518 if (selected) {
1519 ImGui::SetItemDefaultFocus();
1520 }
1521 }
1522 ImGui::EndCombo();
1523 }
1524 ImGui::PushTextWrapPos(0.0f);
1525 ImGui::TextDisabled("%s", tr(selected_family->description));
1526 ImGui::PopTextWrapPos();
1527
1528 const auto name_strip = ReserveBrowserNameStrip();
1529 std::string browser_name = absl::StrFormat(
1533 {
1534 const ImGuiStyle& style = ImGui::GetStyle();
1535 const float item_spacing = std::max(2.0f, style.ItemSpacing.x * 0.5f);
1536 const auto grid_layout = ResolveDungeonObjectSelectorGridLayout(
1537 ImGui::GetContentRegionAvail().x,
1538 GetObjectGridItemSize(object_grid_density_), item_spacing);
1539 const int columns = grid_layout.columns;
1540 const float item_size = grid_layout.item_size;
1541 gui::StyleVarGuard grid_spacing_guard(
1542 ImGuiStyleVar_ItemSpacing,
1543 ImVec2(item_spacing, std::max(item_spacing, style.ItemSpacing.y)));
1544 int custom_col = 0;
1545 for (const int obj_id : zelda3::CustomObjectManager::RuntimeObjectIds()) {
1546 if (obj_id != custom_asset_family_id_) {
1547 continue;
1548 }
1549 const int subtype_count = std::min(obj_manager.GetSubtypeCount(obj_id),
1550 kPersistedCustomSubtypeSlots);
1551 for (int subtype = 0; subtype < subtype_count; ++subtype) {
1552 const std::string subtype_name =
1553 GetDungeonCustomObjectSlotName(obj_id, subtype);
1554
1555 if (custom_col > 0) {
1556 ImGui::SameLine(0.0f, item_spacing);
1557 } else {
1558 ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
1559 grid_layout.leading_inset);
1560 }
1561
1562 ImGui::PushID(obj_id * 1000 + subtype);
1563
1564 const absl::Status asset_status =
1565 GetCustomObjectAssetStatus(obj_id, subtype);
1566 const bool asset_ready = asset_status.ok();
1567
1568 const bool is_selected = custom_asset_family_id_ == obj_id &&
1569 custom_asset_subtype_ == subtype;
1570 ImVec2 button_size(item_size, item_size);
1571
1572 if (ImGui::Selectable("", is_selected, 0, button_size)) {
1573 custom_asset_family_id_ = obj_id;
1574 custom_asset_subtype_ = subtype;
1576 }
1577 const bool item_visible = ImGui::IsItemVisible();
1578 ImVec2 button_pos = ImGui::GetItemRectMin();
1579 DrawObjectContextMenu(obj_id, subtype);
1580 if (asset_ready && rooms_ != nullptr &&
1581 rooms_->GetIfLoaded(current_room_id_) != nullptr) {
1583 static_cast<uint16_t>(obj_id), current_room_id_, 0, 0,
1585 static_cast<uint8_t>(subtype)));
1586 }
1587 ImDrawList* draw_list = ImGui::GetWindowDrawList();
1588 const bool show_id = item_size >= 44.0f;
1589 const float footer_height =
1590 show_id ? std::min(item_size * 0.32f, ImGui::GetFontSize() + 3.0f)
1591 : 0.0f;
1592 const float card_padding = std::min(3.0f, item_size * 0.08f);
1593 const ImVec2 preview_pos(button_pos.x + card_padding,
1594 button_pos.y + card_padding);
1595 const ImVec2 preview_box(
1596 std::max(1.0f, item_size - card_padding * 2.0f),
1597 std::max(1.0f, item_size - footer_height - card_padding * 2.0f));
1598
1599 bool rendered = false;
1600 if (item_visible && enable_object_previews_) {
1601 auto temp_obj = MakePreviewObject(obj_id);
1602 temp_obj.size_ = zelda3::CanonicalRoomObjectSize(
1603 obj_id, static_cast<uint8_t>(subtype));
1604 rendered = DrawObjectPreview(temp_obj, preview_pos, preview_box);
1605 }
1606
1607 if (item_visible && !rendered) {
1608 std::string sub_text = absl::StrFormat("%02X", subtype);
1609 DrawFallbackPreviewTile(
1610 draw_list, preview_pos, preview_box,
1611 asset_ready ? theme.status_success : theme.status_error,
1612 sub_text.c_str());
1613 }
1614
1615 const bool item_hovered = ImGui::IsItemHovered();
1616 if (item_visible && (is_selected || item_hovered)) {
1617 const ImU32 border_color =
1618 ImGui::GetColorU32(is_selected ? theme.dungeon_selection_primary
1619 : theme.panel_border_color);
1620 draw_list->AddRect(
1621 button_pos,
1622 ImVec2(button_pos.x + item_size, button_pos.y + item_size),
1623 border_color, 2.0f, is_selected ? 2.0f : 1.0f);
1624 }
1625
1626 if (item_visible && show_id) {
1627 const ImVec2 card_bottom(button_pos.x + item_size,
1628 button_pos.y + item_size);
1629 const float footer_y = card_bottom.y - footer_height;
1630 draw_list->AddRectFilled(
1631 ImVec2(button_pos.x, footer_y), card_bottom,
1632 ImGui::GetColorU32(WithAlpha(theme.panel_bg_darker, 0.88f)),
1633 2.0f);
1634 std::string id_text = absl::StrFormat("%02X:%02X", obj_id, subtype);
1635 ImVec2 id_size = ImGui::CalcTextSize(id_text.c_str());
1636 ImVec2 id_pos = ImVec2(button_pos.x + (item_size - id_size.x) / 2,
1637 footer_y + (footer_height - id_size.y) * 0.5f);
1638 draw_list->AddText(id_pos, ImGui::GetColorU32(theme.text_primary),
1639 id_text.c_str());
1640 }
1641
1642 if (item_hovered) {
1643 browser_name =
1644 absl::StrFormat("0x%02X:%02X %s", obj_id, subtype, subtype_name);
1645 }
1646
1647 ImGui::PopID();
1648 custom_col = (custom_col + 1) % columns;
1649 }
1650 }
1651 }
1652 DrawBrowserNameStrip(name_strip, browser_name);
1653 ImGui::Separator();
1654 const std::string selected_name = GetDungeonCustomObjectSlotName(
1656 const auto selected_binding_or = obj_manager.ResolveSlotBinding(
1658 const std::string selected_filename =
1659 selected_binding_or.ok() ? selected_binding_or->filename : "";
1660 const absl::Status selected_asset_status = GetCustomObjectAssetStatus(
1662 const auto selected_path_or =
1663 zelda3::ResolveCustomObjectAssetPath(custom_base_path, selected_filename);
1664 const bool selected_asset_ready = selected_asset_status.ok();
1665 ImGui::TextColored(theme.selection_primary, "0x%02X:%02X %s",
1667 selected_name.c_str());
1668 ImGui::SameLine();
1669 ImGui::TextDisabled("%s", selected_filename.empty()
1670 ? "(unmapped)"
1671 : selected_filename.c_str());
1672 if (selected_binding_or.ok()) {
1673 const ImVec4 mapping_color =
1674 selected_binding_or->origin ==
1676 ? theme.status_active
1677 : (selected_binding_or->origin ==
1678 zelda3::CustomObjectMappingOrigin::
1679 kConfiguredSlotUnmapped
1680 ? theme.status_error
1681 : theme.text_secondary_gray);
1682 ImGui::TextColored(
1683 mapping_color, tr("Mapping: %s"),
1684 tr(CustomObjectMappingOriginLabel(selected_binding_or->origin)));
1685 }
1686 if (selected_asset_ready) {
1687 ImGui::TextColored(theme.status_success,
1688 ICON_MD_CHECK_CIRCLE " Asset ready");
1689 if (selected_path_or.ok() && ImGui::IsItemHovered()) {
1690 ImGui::SetTooltip("%s", selected_path_or->string().c_str());
1691 }
1692 } else {
1693 ImGui::TextColored(theme.status_error, ICON_MD_ERROR " %s",
1694 selected_asset_status.message().data());
1695 }
1696 ImGui::PushTextWrapPos(0.0f);
1697 if (custom_asset_family_id_ == 0x31 && custom_asset_subtype_ == 13) {
1698 ImGui::TextDisabled(
1699 "%s", tr("Custom 0x31 wall object; separate from vanilla wall and "
1700 "corner tile tables"));
1703 ImGui::TextDisabled("%s", tr("Tile layout only; behavior is separate"));
1704 }
1705 if (custom_asset_family_id_ == 0x54) {
1706 ImGui::TextColored(theme.text_warning_yellow, ICON_MD_WARNING
1707 " Tilemap editing is available; external boss pixels "
1708 "are not loaded into the room preview yet.");
1709 if (ImGui::IsItemHovered()) {
1710 ImGui::SetTooltip(
1711 "%s",
1712 tr("Oracle forces nonzero body tiles into graphics page 0x300 and "
1713 "DMA-loads separate Kydreeok or Manhandla graphics at runtime. "
1714 "Yaze currently preserves the correct tilemap geometry and raw "
1715 "source words, but the displayed pixels are not visual-parity "
1716 "evidence."));
1717 }
1718 }
1719 ImGui::PopTextWrapPos();
1720 if (!custom_object_action_error_.empty()) {
1721 ImGui::TextColored(theme.status_error, ICON_MD_ERROR " %s",
1723 }
1724
1725 const bool has_room_context =
1726 rooms_ != nullptr && rooms_->GetIfLoaded(current_room_id_) != nullptr;
1727#if defined(__EMSCRIPTEN__)
1728 constexpr bool kCanPublishCustomAssets = false;
1729#else
1730 constexpr bool kCanPublishCustomAssets = true;
1731#endif
1732 const bool can_edit = selected_asset_ready && has_room_context &&
1733 tile_editor_panel_ != nullptr &&
1734 kCanPublishCustomAssets;
1735 const bool can_use_in_room = selected_asset_ready && has_room_context;
1736 if (ImGui::BeginTable(
1737 "##CustomAssetActions", 2,
1738 ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX)) {
1739 ImGui::TableNextColumn();
1740 if (!can_edit) {
1741 ImGui::BeginDisabled();
1742 }
1743 if (ImGui::Button(ICON_MD_EDIT " Edit Tile Layout", ImVec2(-1.0f, 0.0f))) {
1744 const absl::Status status = OpenExistingCustomObjectEditor(
1745 static_cast<int16_t>(custom_asset_family_id_), custom_asset_subtype_,
1747 if (status.ok()) {
1749 } else {
1750 custom_object_action_error_ = std::string(status.message());
1751 }
1752 }
1753 if (!can_edit) {
1754 ImGui::EndDisabled();
1755 }
1756 if (!kCanPublishCustomAssets &&
1757 ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1758 ImGui::SetTooltip(
1759 "%s", tr("Custom asset publishing is desktop-only until browser "
1760 "persistence can be verified."));
1761 }
1762
1763 ImGui::TableNextColumn();
1764 if (!can_use_in_room) {
1765 ImGui::BeginDisabled();
1766 }
1767 if (ImGui::Button(ICON_MD_ADD " Place in Room", ImVec2(-1.0f, 0.0f))) {
1770 }
1771 if (!can_use_in_room) {
1772 ImGui::EndDisabled();
1773 }
1774 ImGui::EndTable();
1775 }
1776
1779 const bool can_open_minecart =
1780 static_cast<bool>(open_minecart_editor_window_callback_);
1781 if (!can_open_minecart) {
1782 ImGui::BeginDisabled();
1783 }
1784 if (ImGui::Button(ICON_MD_TRAIN " Minecart Routes & Collision",
1785 ImVec2(-1.0f, 0.0f))) {
1788 } else {
1790 "Minecart Tracks window is unavailable in this session";
1791 }
1792 }
1793 if (!can_open_minecart) {
1794 ImGui::EndDisabled();
1795 }
1796 }
1797}
1798
1799} // namespace yaze::editor
bool is_loaded() const
Definition rom.h:199
static Flags & get()
Definition features.h:122
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
void DrawObjectDetails(int object_id, int subtype)
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_
workbench::DungeonBrowserState browser_state_
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 scope that enables automatic widget registration.
RAII guard for ImGui style vars.
Definition style_guard.h:71
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)
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_TUNE
Definition icons.h:2022
#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_CLOSE
Definition icons.h:418
#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)
void DrawBrowserNameStrip(const BrowserNameStrip &strip, const std::string &label)
bool DungeonBrowserMatches(std::string_view query, int id, std::string_view name)
void DrawDungeonBrowserControls(DungeonBrowserState &state, std::span< const char *const > categories)
std::string DungeonBrowserFold(std::string_view value)
bool DrawDungeonBrowserRow(int id, const std::string &name, bool selected, DrawPreview preview, std::string_view badge={})
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)
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.