yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
message_editor.cc
Go to the documentation of this file.
1#include "message_editor.h"
2#include "util/i18n/tr.h"
3
4#include <algorithm>
5#include <limits>
6#include <string>
7#include <unordered_map>
8#include <vector>
9
10#include "absl/status/status.h"
11#include "absl/strings/match.h"
12#include "absl/strings/str_cat.h"
13#include "absl/strings/str_format.h"
17#include "app/gfx/core/bitmap.h"
23#include "app/gui/core/icons.h"
24#include "app/gui/core/input.h"
25#include "app/gui/core/style.h"
27#include "imgui.h"
28#include "imgui/misc/cpp/imgui_stdlib.h"
29#include "rom/rom.h"
30#include "rom/transaction.h"
31#include "rom/write_fence.h"
32#include "util/file_util.h"
33#include "util/hex.h"
34#include "util/log.h"
35
36namespace yaze {
37namespace editor {
38
39using ImGui::BeginChild;
40using ImGui::BeginTable;
41using ImGui::Button;
42using ImGui::EndChild;
43using ImGui::EndTable;
44using ImGui::InputTextMultiline;
45using ImGui::PopID;
46using ImGui::PushID;
47using ImGui::SameLine;
48using ImGui::Separator;
49using ImGui::TableHeadersRow;
50using ImGui::TableNextColumn;
51using ImGui::TableSetupColumn;
52using ImGui::Text;
53using ImGui::TextWrapped;
54
55constexpr ImGuiTableFlags kMessageTableFlags = ImGuiTableFlags_Hideable |
56 ImGuiTableFlags_Borders |
57 ImGuiTableFlags_Resizable;
58
61 dirty_state_ = {};
66 // Register panels with WorkspaceWindowManager (dependency injection)
68 return;
69
70 auto* window_manager = dependencies_.window_manager;
71 const size_t session_id = dependencies_.session_id;
72
73 // Register WindowContent implementations (they provide both metadata and drawing)
74 window_manager->RegisterWindowContent(
75 std::make_unique<MessageListPanel>([this]() { DrawMessageList(); }));
76 window_manager->RegisterWindowContent(
77 std::make_unique<MessageEditorPanel>([this]() { DrawCurrentMessage(); }));
78 window_manager->RegisterWindowContent(
79 std::make_unique<FontAtlasPanel>([this]() {
82 }));
83 window_manager->RegisterWindowContent(
84 std::make_unique<DictionaryPanel>([this]() {
88 }));
89
90 // Show message list by default
91 window_manager->OpenWindow(session_id, "message.message_list");
92
93 for (int i = 0; i < kWidthArraySize; i++) {
95 }
96
98 const int message_read_limit = static_cast<int>(
99 std::min(static_cast<size_t>(rom()->size()),
100 static_cast<size_t>(std::numeric_limits<int>::max())));
102 ReadAllTextData(rom()->mutable_data(), kTextData, message_read_limit);
103 LOG_INFO("MessageEditor", "Loaded %zu messages from ROM",
104 list_of_texts_.size());
105
110 }
113
115
116 if (!list_of_texts_.empty()) {
117 // Default to message 1 if available, otherwise 0
118 size_t default_idx = list_of_texts_.size() > 1 ? 1 : 0;
119 current_message_ = list_of_texts_[default_idx];
123 current_parse_errors_.clear();
126 } else {
127 LOG_ERROR("MessageEditor", "No messages found in ROM!");
128 }
129}
130
131bool MessageEditor::OpenMessageById(int display_id) {
132 // Do not discard an invalid in-progress draft by navigating away. The user
133 // can correct it or undo it before selecting another message.
134 if (!current_parse_errors_.empty()) {
135 return false;
136 }
137
138 const int vanilla_count = static_cast<int>(list_of_texts_.size());
139 const int expanded_base_id = expanded_message_base_id_;
140
141 int expanded_count = static_cast<int>(expanded_messages_.size());
142 auto resolved = ResolveMessageDisplayId(display_id, vanilla_count,
143 expanded_base_id, expanded_count);
144
145 // Convenience: if an expanded ID is requested but we haven't loaded expanded
146 // messages yet, try loading from ROM once.
147 if (!resolved.has_value() && expanded_count == 0 &&
148 display_id >= expanded_base_id && rom_ && rom_->is_loaded()) {
149 const int start = GetExpandedTextDataStart();
150 const int end = GetExpandedTextDataEnd();
151 const size_t rom_size = rom_->size();
152 if (start >= 0 && end >= start && static_cast<size_t>(end) < rom_size) {
153 const auto status = LoadExpandedMessagesFromRom();
154 if (!status.ok()) {
155 LOG_DEBUG("MessageEditor",
156 "OpenMessageById: expanded load skipped/failed: %s",
157 std::string(status.message()).c_str());
158 }
159 expanded_count = static_cast<int>(expanded_messages_.size());
160 resolved = ResolveMessageDisplayId(display_id, vanilla_count,
161 expanded_base_id, expanded_count);
162 } else {
163 LOG_DEBUG("MessageEditor",
164 "OpenMessageById: expanded region out of bounds (0x%X-0x%X, "
165 "rom=0x%zX)",
166 start, end, rom_size);
167 }
168 }
169
170 if (!resolved.has_value()) {
171 return false;
172 }
173
175 const size_t session_id = dependencies_.session_id;
177 "message.message_list");
179 "message.message_editor");
180 }
181
182 if (!resolved->is_expanded) {
183 const int idx = resolved->index;
184 if (idx < 0 || idx >= vanilla_count) {
185 return false;
186 }
187
188 const auto& message = list_of_texts_[idx];
189 current_message_ = message;
190 current_message_index_ = message.ID;
192
193 const int parsed_idx = resolved->display_id;
194 if (parsed_idx >= 0 &&
195 parsed_idx < static_cast<int>(parsed_messages_.size())) {
197 } else {
198 message_text_box_.text.clear();
199 }
200
201 current_parse_errors_.clear();
204 return true;
205 }
206
207 // Expanded message.
208 const int idx = resolved->index;
209 if (idx < 0 || idx >= expanded_count) {
210 return false;
211 }
212
213 const auto& message = expanded_messages_[idx];
214 current_message_ = message;
215 current_message_index_ = message.ID;
217
218 const int parsed_idx = resolved->display_id;
219 if (parsed_idx >= 0 &&
220 parsed_idx < static_cast<int>(parsed_messages_.size())) {
222 } else {
223 message_text_box_.text.clear();
224 }
225
226 current_parse_errors_.clear();
229 return true;
230}
231
233 int base_id = static_cast<int>(list_of_texts_.size());
235 const auto& layout = dependencies_.project->hack_manifest.message_layout();
236 if (layout.first_expanded_id != 0) {
237 base_id = static_cast<int>(layout.first_expanded_id);
238 }
239 }
240
241 // Never allow the expanded base to precede the vanilla message count; this
242 // prevents truncating/overlapping IDs when the manifest is missing/mistyped.
243 base_id = std::max(base_id, static_cast<int>(list_of_texts_.size()));
244 return base_id;
245}
246
248 if (game_data() && !game_data()->palette_groups.hud.empty()) {
250 }
251
254 }
255}
256
258 std::vector<gfx::SnesColor> colors;
259 colors.reserve(16);
260 for (int i = 0; i < 16; ++i) {
261 const float value = static_cast<float>(i) / 15.0f;
262 colors.emplace_back(ImVec4(value, value, value, 1.0f));
263 }
264
265 if (!colors.empty()) {
266 colors[0].set_transparent(true);
267 }
268
269 return gfx::SnesPalette(colors);
270}
271
274 if (!rom() || !rom()->is_loaded()) {
275 LOG_WARN("MessageEditor", "ROM not loaded - skipping font graphics load");
276 return;
277 }
278
279 std::fill(raw_font_gfx_data_.begin(), raw_font_gfx_data_.end(), 0);
280
281 const size_t rom_size = rom()->size();
282 if (rom_size > static_cast<size_t>(kGfxFont)) {
283 const size_t available = std::min(raw_font_gfx_data_.size(),
284 rom_size - static_cast<size_t>(kGfxFont));
285 std::copy_n(rom()->data() + kGfxFont, available,
287 if (available < raw_font_gfx_data_.size()) {
288 LOG_WARN("MessageEditor",
289 "Font graphics truncated (ROM size %zu, read %zu bytes)",
290 rom_size, available);
291 }
292 } else {
293 LOG_WARN("MessageEditor",
294 "ROM size %zu too small for font graphics offset 0x%X", rom_size,
295 kGfxFont);
296 }
297
299 gfx::SnesTo8bppSheet(raw_font_gfx_data_, /*bpp=*/2, /*num_sheets=*/2);
300
301 auto load_font = zelda3::LoadFontGraphics(*rom());
302 if (load_font.ok()) {
303 message_preview_.font_gfx16_data_2_ = load_font.value().vector();
304 } else {
305 const std::string error_message(load_font.status().message());
306 LOG_WARN("MessageEditor", "LoadFontGraphics failed: %s",
307 error_message.c_str());
308 }
309
310 const auto& font_data = !message_preview_.font_gfx16_data_.empty()
313 RefreshFontAtlasBitmap(font_data);
315}
316
318 const std::vector<uint8_t>& font_data) {
319 if (font_data.empty()) {
320 LOG_WARN("MessageEditor", "Font graphics data missing - atlas stays empty");
321 return;
322 }
323
324 const int atlas_width = kFontGfxMessageSize;
325 const size_t row_count = (font_data.size() + atlas_width - 1) / atlas_width;
326 const int atlas_height = static_cast<int>(std::max<size_t>(1, row_count));
327
328 const size_t expected_size = static_cast<size_t>(atlas_width) * atlas_height;
329 std::vector<uint8_t> padded(font_data.begin(), font_data.end());
330 if (padded.size() < expected_size) {
331 padded.resize(expected_size, 0);
332 } else if (padded.size() > expected_size) {
333 padded.resize(expected_size);
334 }
335
336 font_gfx_bitmap_.Create(atlas_width, atlas_height, kFontGfxMessageDepth,
337 padded);
340 }
343}
344
345absl::Status MessageEditor::Load() {
346 gfx::ScopedTimer timer("MessageEditor::Load");
347 return absl::OkStatus();
348}
349
350absl::Status MessageEditor::Update() {
351 // Panel drawing is handled centrally by WorkspaceWindowManager::DrawAllVisiblePanels()
352 // via the WindowContent implementations registered in Initialize().
353 // No local drawing needed here.
354 return absl::OkStatus();
355}
356
361
364
366 return;
367 }
368
369 auto queue_refresh = [](gfx::Bitmap& bitmap) {
370 if (!bitmap.is_active()) {
371 return;
372 }
373 const auto command = bitmap.texture()
376 gfx::Arena::Get().QueueTextureCommand(command, &bitmap);
377 };
378
380 queue_refresh(font_gfx_bitmap_);
381
384 queue_refresh(current_font_gfx16_bitmap_);
385 }
386}
387
406
409 if (BeginChild("##MessagesList", ImVec2(0, 0), true,
410 ImGuiWindowFlags_AlwaysVerticalScrollbar)) {
412 if (ImGui::Button(tr("Import Bundle"))) {
414 if (!path.empty()) {
416 }
417 }
418 ImGui::SameLine();
419 if (ImGui::Button(tr("Export Bundle"))) {
421 if (!path.empty()) {
422 auto status =
424 if (!status.ok()) {
426 absl::StrFormat("Export failed: %s", status.message());
428 } else {
429 message_bundle_status_ = absl::StrFormat("Exported bundle: %s", path);
431 }
432 }
433 }
434 if (!message_bundle_status_.empty()) {
437 ImGui::TextColored(color, "%s", message_bundle_status_.c_str());
438 }
439 ImGui::Separator();
440 if (BeginTable("##MessagesTable", 4, kMessageTableFlags)) {
441 TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 50);
442 TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 80);
443 TableSetupColumn("Contents", ImGuiTableColumnFlags_WidthStretch);
444 TableSetupColumn("Address", ImGuiTableColumnFlags_WidthFixed, 100);
445
446 TableHeadersRow();
447
448 // Calculate total rows for clipper
449 const int vanilla_count = static_cast<int>(list_of_texts_.size());
450 const int expanded_count = static_cast<int>(expanded_messages_.size());
451 const int total_rows = vanilla_count + expanded_count;
452
453 // Use ImGuiListClipper for virtualized rendering
454 ImGuiListClipper clipper;
455 clipper.Begin(total_rows);
456
457 while (clipper.Step()) {
458 for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
459 if (row < vanilla_count) {
460 // Vanilla message
461 const auto& message = list_of_texts_[row];
462 TableNextColumn();
463 PushID(message.ID);
464 if (Button(util::HexWord(message.ID).c_str())) {
465 if (current_parse_errors_.empty()) {
467 OpenMessageById(message.ID);
468 }
469 }
470 PopID();
471
472 TableNextColumn();
473 ImGui::TextColored(gui::GetInfoColor(), tr("Vanilla"));
474
475 TableNextColumn();
476 TextWrapped("%s", parsed_messages_[message.ID].c_str());
477
478 TableNextColumn();
479 TextWrapped("%s", util::HexLong(message.Address).c_str());
480 } else {
481 // Expanded message
482 int expanded_idx = row - vanilla_count;
483 const auto& expanded_message = expanded_messages_[expanded_idx];
484 const int display_id =
485 expanded_message_base_id_ + expanded_message.ID;
486 const char* display_text = "Missing text";
487 if (display_id >= 0 &&
488 display_id < static_cast<int>(parsed_messages_.size())) {
489 display_text = parsed_messages_[display_id].c_str();
490 }
491 TableNextColumn();
492 PushID(display_id);
493 if (Button(util::HexWord(display_id).c_str())) {
494 if (current_parse_errors_.empty()) {
496 OpenMessageById(display_id);
497 }
498 }
499 PopID();
500
501 TableNextColumn();
502 ImGui::TextColored(gui::GetWarningColor(), tr("Expanded"));
503
504 TableNextColumn();
505 TextWrapped("%s", display_text);
506
507 TableNextColumn();
508 TextWrapped("%s", util::HexLong(expanded_message.Address).c_str());
509 }
510 }
511 }
512
513 EndTable();
514 }
515 }
516 EndChild();
517}
518
520 Button(absl::StrCat("Message ", current_message_.ID).c_str());
521 if (InputTextMultiline("##MessageEditor", &message_text_box_.text,
522 ImVec2(ImGui::GetContentRegionAvail().x, 0))) {
524 }
525 if (ImGui::IsItemDeactivatedAfterEdit()) {
527 }
528 if (!current_parse_errors_.empty()) {
529 ImGui::TextColored(gui::GetErrorColor(), tr("Message parse errors"));
530 for (const auto& error : current_parse_errors_) {
531 ImGui::BulletText("%s", error.c_str());
532 }
533 }
534 if (!current_parse_warnings_.empty()) {
535 ImGui::TextColored(gui::GetWarningColor(), tr("Message parse warnings"));
536 for (const auto& warning : current_parse_warnings_) {
537 ImGui::BulletText("%s", warning.c_str());
538 }
539 }
541 if (!line_warnings.empty()) {
542 ImGui::TextColored(gui::GetWarningColor(), tr("Line width warnings"));
543 for (const auto& warning : line_warnings) {
544 ImGui::BulletText("%s", warning.c_str());
545 }
546 }
547 Separator();
549
550 ImGui::BeginChild("##MessagePreview", ImVec2(0, 0), true);
552 Text(tr("Message Preview"));
553 if (Button(tr("View Palette"))) {
554 ImGui::OpenPopup("Palette");
555 }
556 if (ImGui::BeginPopup("Palette")) {
558 ImGui::EndPopup();
559 }
561 BeginChild("CurrentGfxFont", ImVec2(348, 0), true,
562 ImGuiWindowFlags_NoScrollWithMouse);
568
569 // Handle mouse wheel scrolling
570 if (ImGui::IsWindowHovered()) {
571 float wheel = ImGui::GetIO().MouseWheel;
572 if (wheel > 0 && message_preview_.shown_lines > 0) {
574 } else if (wheel < 0 &&
577 }
578 }
579
580 // Draw only the visible portion of the text
581 const ImVec2 preview_canvas_size = current_font_gfx16_canvas_.canvas_size();
582 const float dest_width = std::max(0.0f, preview_canvas_size.x - 8.0f);
583 const float dest_height = std::max(0.0f, preview_canvas_size.y - 8.0f);
584 float src_height = 0.0f;
585 if (dest_width > 0.0f && dest_height > 0.0f) {
586 const float src_width =
587 std::min(dest_width * 0.5f, static_cast<float>(kCurrentMessageWidth));
588 src_height =
589 std::min(dest_height * 0.5f, static_cast<float>(kCurrentMessageHeight));
591 current_font_gfx16_bitmap_, ImVec2(0, 0), // Destination position
592 ImVec2(dest_width, dest_height), // Destination size
593 ImVec2(0, message_preview_.shown_lines * 16), // Source position
594 ImVec2(src_width, src_height) // Source size
595 );
596 }
597
598 // Draw scroll break separator lines on the preview canvas
599 {
600 ImDrawList* overlay_draw_list = ImGui::GetWindowDrawList();
601 ImVec2 canvas_p0 = current_font_gfx16_canvas_.zero_point();
602 ImVec2 canvas_sz = current_font_gfx16_canvas_.canvas_size();
603 float line_height = 16.0f;
604 // The bitmap is drawn scaled: dest occupies full canvas, so compute the
605 // vertical scale factor from destination height to source height.
606 float scale_y = 1.0f;
607 if (dest_height > 0.0f && src_height > 0.0f) {
608 scale_y = dest_height / src_height;
609 }
610 for (int marker_line : message_preview_.scroll_marker_lines) {
611 float src_y = (marker_line - message_preview_.shown_lines) * line_height;
612 float y = canvas_p0.y + src_y * scale_y;
613 if (y >= canvas_p0.y && y <= canvas_p0.y + canvas_sz.y) {
614 overlay_draw_list->AddLine(ImVec2(canvas_p0.x, y),
615 ImVec2(canvas_p0.x + canvas_sz.x, y),
616 IM_COL32(100, 180, 255, 180), 1.5f);
617 overlay_draw_list->AddText(ImVec2(canvas_p0.x + canvas_sz.x + 4, y - 6),
618 IM_COL32(100, 180, 255, 200), "[V]");
619 }
620 }
621 }
622
625 EndChild();
626
627 // Message Structure info panel
628 if (ImGui::CollapsingHeader(tr("Message Structure"),
629 ImGuiTreeNodeFlags_DefaultOpen)) {
630 ImGui::Text(tr("Lines: %d"), message_preview_.text_line + 1);
631
632 int scroll_count = 0;
633 int current_line_chars = 0;
634 int line_num = 0;
635
636 for (size_t i = 0; i < current_message_.Data.size(); i++) {
637 uint8_t byte = current_message_.Data[i];
638 if (byte == kScrollVertical) {
639 scroll_count++;
640 ImGui::TextColored(gui::GetInfoColor(),
641 tr(" [V] Scroll at byte %zu (line %d, %d chars)"),
642 i, line_num, current_line_chars);
643 current_line_chars = 0;
644 line_num++;
645 } else if (byte == kLine1) {
646 ImGui::TextColored(ImVec4(0.7f, 0.85f, 0.5f, 1.0f),
647 tr(" [1] Line 1 at byte %zu"), i);
648 current_line_chars = 0;
649 line_num = 0;
650 } else if (byte == kLine2) {
651 ImGui::TextColored(ImVec4(0.7f, 0.85f, 0.5f, 1.0f),
652 tr(" [2] Line 2 at byte %zu"), i);
653 current_line_chars = 0;
654 line_num = 1;
655 } else if (byte == kLine3) {
656 ImGui::TextColored(ImVec4(0.7f, 0.85f, 0.5f, 1.0f),
657 tr(" [3] Line 3 at byte %zu"), i);
658 current_line_chars = 0;
659 line_num = 2;
660 } else if (byte < 100) {
661 current_line_chars++;
662 }
663 }
664
665 if (scroll_count == 0) {
666 ImGui::TextDisabled(tr("No scroll breaks in this message"));
667 } else {
668 ImGui::Text(tr("Total scroll breaks: %d"), scroll_count);
669 }
670
671 // Character width budget
672 ImGui::Separator();
673 ImGui::TextDisabled(tr("Line width budget (max ~170px):"));
674 int estimated_line_width = current_line_chars * 8;
675 float width_ratio = static_cast<float>(estimated_line_width) / 170.0f;
676 ImVec4 width_color = (width_ratio > 1.0f) ? gui::GetErrorColor()
677 : (width_ratio > 0.85f) ? gui::GetWarningColor()
679 ImGui::TextColored(width_color, tr("Last line: ~%dpx / 170px (%d chars)"),
680 estimated_line_width, current_line_chars);
681 }
682
683 ImGui::EndChild();
684}
685
694
696 ImGui::BeginChild("##ExpandedMessageSettings", ImVec2(0, 130), true,
697 ImGuiWindowFlags_AlwaysVerticalScrollbar);
698 ImGui::Text(tr("Expanded Messages"));
699
700 if (ImGui::Button(tr("Load from ROM"))) {
701 auto status = LoadExpandedMessagesFromRom();
702 if (!status.ok()) {
703 LOG_WARN("MessageEditor", "Load from ROM: %s",
704 std::string(status.message()).c_str());
705 }
706 }
707 ImGui::SameLine();
708 if (ImGui::Button(tr("Load from File"))) {
710 if (!path.empty()) {
714 expanded_messages_.clear();
715 std::vector<std::string> parsed_expanded;
717 parsed_expanded, expanded_messages_,
719 if (!status.ok()) {
720 if (auto* popup_manager = dependencies_.popup_manager) {
721 popup_manager->Show("Error");
722 }
723 } else {
724 parsed_messages_.insert(parsed_messages_.end(), parsed_expanded.begin(),
725 parsed_expanded.end());
727 }
728 }
729 }
730
731 if (expanded_messages_.size() > 0) {
732 ImGui::Text(tr("Source: %s"), expanded_message_path_.c_str());
733 ImGui::Text(tr("Messages: %lu"), expanded_messages_.size());
734
735 // Capacity indicator
736 int capacity = GetExpandedTextDataEnd() - GetExpandedTextDataStart() + 1;
737 int used = CalculateExpandedBankUsage();
738 int remaining = capacity - used;
739 float usage_ratio = static_cast<float>(used) / static_cast<float>(capacity);
740
741 ImVec4 capacity_color;
742 if (usage_ratio < 0.75f) {
743 capacity_color = gui::GetSuccessColor();
744 } else if (usage_ratio < 0.90f) {
745 capacity_color = gui::GetWarningColor();
746 } else {
747 capacity_color = gui::GetErrorColor();
748 }
749 ImGui::TextColored(capacity_color, tr("Bank: %d / %d bytes (%d free)"),
750 used, capacity, remaining);
751
752 if (ImGui::Button(tr("Add New Message"))) {
753 MessageData new_message;
754 new_message.ID = expanded_messages_.back().ID + 1;
755 new_message.Address = expanded_messages_.back().Address +
756 expanded_messages_.back().Data.size();
757 expanded_messages_.push_back(new_message);
759 const int display_id = expanded_message_base_id_ + new_message.ID;
760 if (display_id >= 0 &&
761 static_cast<size_t>(display_id) >= parsed_messages_.size()) {
762 parsed_messages_.resize(display_id + 1);
763 }
764 }
765
766 ImGui::SameLine();
767 if (ImGui::Button(tr("Export to JSON"))) {
769 if (!path.empty()) {
771 }
772 }
773 }
774
775 EndChild();
776}
777
779 ImGui::BeginChild("##TextCommands",
780 ImVec2(0, ImGui::GetContentRegionAvail().y / 2), true,
781 ImGuiWindowFlags_AlwaysVerticalScrollbar);
782 static uint8_t command_parameter = 0;
783 gui::InputHexByte("Command Parameter", &command_parameter);
784 for (const auto& text_element : TextCommands) {
785 if (Button(text_element.GenericToken.c_str())) {
786 message_text_box_.text.append(
787 text_element.GetParamToken(command_parameter));
789 }
790 SameLine();
791 TextWrapped("%s", text_element.Description.c_str());
792 Separator();
793 }
794 EndChild();
795}
796
798 ImGui::BeginChild("##SpecialChars",
799 ImVec2(0, ImGui::GetContentRegionAvail().y / 2), true,
800 ImGuiWindowFlags_AlwaysVerticalScrollbar);
801 for (const auto& text_element : SpecialChars) {
802 if (Button(text_element.GenericToken.c_str())) {
803 message_text_box_.text.append(text_element.GenericToken);
805 }
806 SameLine();
807 TextWrapped("%s", text_element.Description.c_str());
808 Separator();
809 }
810 EndChild();
811}
812
814 if (ImGui::BeginChild("##DictionaryChild",
815 ImVec2(0, ImGui::GetContentRegionAvail().y), true,
816 ImGuiWindowFlags_AlwaysVerticalScrollbar)) {
817 if (BeginTable("##Dictionary", 2, kMessageTableFlags)) {
818 TableSetupColumn("ID");
819 TableSetupColumn("Contents");
820 TableHeadersRow();
821
822 // Use ImGuiListClipper for virtualized rendering
823 const int dict_count =
824 static_cast<int>(message_preview_.all_dictionaries_.size());
825 ImGuiListClipper clipper;
826 clipper.Begin(dict_count);
827
828 while (clipper.Step()) {
829 for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
830 const auto& dictionary = message_preview_.all_dictionaries_[row];
831 TableNextColumn();
832 Text("%s", util::HexWord(dictionary.ID).c_str());
833 TableNextColumn();
834 Text("%s", dictionary.Contents.c_str());
835 }
836 }
837
838 EndTable();
839 }
840 }
841 EndChild();
842}
843
844void MessageEditor::UpdateCurrentMessageFromText(const std::string& text) {
846
849
850 auto parse_result = ParseMessageToDataWithDiagnostics(text);
851 current_parse_errors_ = parse_result.errors;
852 current_parse_warnings_ = parse_result.warnings;
853 if (rom_) {
854 // Invalid intermediate input is still unsaved work. Save() will fail
855 // closed until the diagnostics are resolved rather than silently dropping
856 // unsupported characters or unknown tokens.
857 rom_->set_dirty(true);
858 }
859 if (!parse_result.ok()) {
860 return;
861 }
862
863 std::string raw_text = text;
864 raw_text.erase(std::remove(raw_text.begin(), raw_text.end(), '\n'),
865 raw_text.end());
866
867 current_message_.RawString = raw_text;
869 current_message_.Data = std::move(parse_result.bytes);
870
871 int parsed_index = current_message_index_;
874 }
875
876 if (parsed_index >= 0) {
877 if (static_cast<size_t>(parsed_index) >= parsed_messages_.size()) {
878 parsed_messages_.resize(parsed_index + 1);
879 }
880 parsed_messages_[parsed_index] = text;
881 }
882
884 if (current_message_index_ >= 0 &&
885 current_message_index_ < static_cast<int>(expanded_messages_.size())) {
887 }
888 } else {
889 if (current_message_index_ >= 0 &&
890 current_message_index_ < static_cast<int>(list_of_texts_.size())) {
892 }
893 }
894
896}
897
898void MessageEditor::ImportMessageBundleFromFile(const std::string& path) {
901
902 auto entries_or = LoadMessageBundleFromJson(path);
903 if (!entries_or.ok()) {
905 absl::StrFormat("Import failed: %s", entries_or.status().message());
907 return;
908 }
909
910 int applied = 0;
911 int errors = 0;
912 int warnings = 0;
913 int duplicate_errors = 0;
914 int parse_error_entries = 0;
915 int vanilla_updated = 0;
916 int expanded_updated = 0;
917 int expanded_created = 0;
918 bool expanded_modified = false;
919 std::vector<std::string> issue_samples;
920
921 auto add_issue_sample = [&issue_samples](const std::string& issue) {
922 constexpr size_t kMaxIssueSamples = 4;
923 if (issue_samples.size() < kMaxIssueSamples) {
924 issue_samples.push_back(issue);
925 }
926 };
927
928 auto make_entry_key = [](const MessageBundleEntry& entry) {
929 return absl::StrFormat("%s:%d", MessageBankToString(entry.bank), entry.id);
930 };
931
932 std::unordered_map<std::string, int> seen_entries;
933
934 auto entries = entries_or.value();
935 for (const auto& entry : entries) {
936 const std::string entry_key = make_entry_key(entry);
937 if (seen_entries.find(entry_key) != seen_entries.end()) {
938 errors++;
939 duplicate_errors++;
940 add_issue_sample(absl::StrFormat("Duplicate entry for %s", entry_key));
941 continue;
942 }
943 seen_entries.emplace(entry_key, 1);
944
945 auto parse_result = ParseMessageToDataWithDiagnostics(entry.text);
946 auto line_warnings = ValidateMessageLineWidths(entry.text);
947 warnings += static_cast<int>(parse_result.warnings.size());
948 warnings += static_cast<int>(line_warnings.size());
949
950 if (!parse_result.ok()) {
951 errors++;
952 parse_error_entries++;
953 if (!parse_result.errors.empty()) {
954 add_issue_sample(absl::StrFormat("Parse error for %s: %s", entry_key,
955 parse_result.errors.front()));
956 } else {
957 add_issue_sample(absl::StrFormat("Parse error for %s", entry_key));
958 }
959 continue;
960 }
961
962 if (entry.bank == MessageBank::kVanilla) {
963 if (entry.id < 0 || entry.id >= static_cast<int>(list_of_texts_.size())) {
964 errors++;
965 add_issue_sample(
966 absl::StrFormat("Vanilla ID out of range: %d", entry.id));
967 continue;
968 }
969 auto& message = list_of_texts_[entry.id];
970 message.RawString = entry.text;
971 message.ContentsParsed = entry.text;
972 message.Data = parse_result.bytes;
973 message.DataParsed = parse_result.bytes;
974 if (entry.id >= 0 &&
975 entry.id < static_cast<int>(parsed_messages_.size())) {
976 parsed_messages_[entry.id] = entry.text;
977 }
978 vanilla_updated++;
979 applied++;
980 } else {
981 if (entry.id < 0) {
982 errors++;
983 add_issue_sample(
984 absl::StrFormat("Expanded ID out of range: %d", entry.id));
985 continue;
986 }
987 if (entry.id >= static_cast<int>(expanded_messages_.size())) {
988 const int old_size = static_cast<int>(expanded_messages_.size());
989 const int target_size = entry.id + 1;
990 expanded_messages_.resize(target_size);
991 for (int i = old_size; i < target_size; ++i) {
992 expanded_messages_[i].ID = i;
993 }
994 expanded_created += target_size - old_size;
995 }
996 auto& message = expanded_messages_[entry.id];
997 message.RawString = entry.text;
998 message.ContentsParsed = entry.text;
999 message.Data = parse_result.bytes;
1000 message.DataParsed = parse_result.bytes;
1001 const int parsed_index = expanded_message_base_id_ + entry.id;
1002 if (parsed_index >= 0) {
1003 if (static_cast<size_t>(parsed_index) >= parsed_messages_.size()) {
1004 parsed_messages_.resize(parsed_index + 1);
1005 }
1006 parsed_messages_[parsed_index] = entry.text;
1007 }
1008 expanded_modified = true;
1009 expanded_updated++;
1010 applied++;
1011 }
1012 }
1013
1014 if (expanded_modified) {
1015 int pos = GetExpandedTextDataStart();
1016 for (auto& message : expanded_messages_) {
1017 message.Address = pos;
1018 pos += static_cast<int>(message.Data.size()) + 1;
1019 }
1020 }
1021
1022 int current_display_id = current_message_index_;
1025 }
1026 if (current_display_id >= 0) {
1027 OpenMessageById(current_display_id);
1028 }
1029
1030 if (errors > 0) {
1031 message_bundle_status_ = absl::StrFormat(
1032 "Import finished with %d errors (%d applied: vanilla %d updated, "
1033 "expanded %d updated/%d created; %d warnings, %d duplicates, %d "
1034 "parse failures).",
1035 errors, applied, vanilla_updated, expanded_updated, expanded_created,
1036 warnings, duplicate_errors, parse_error_entries);
1037 if (!issue_samples.empty()) {
1038 message_bundle_status_ = absl::StrFormat(
1039 "%s Example: %s", message_bundle_status_, issue_samples.front());
1040 }
1042 } else {
1043 message_bundle_status_ = absl::StrFormat(
1044 "Imported %d messages (vanilla %d updated, expanded %d updated/%d "
1045 "created, %d warnings).",
1046 applied, vanilla_updated, expanded_updated, expanded_created, warnings);
1047 }
1048 if (applied > 0 && rom_) {
1049 rom_->set_dirty(true);
1050 }
1051 if (vanilla_updated > 0) {
1053 }
1054 if (expanded_modified) {
1056 }
1057}
1058
1060 // Render the message to the preview bitmap
1062
1063 // Validate preview data before updating
1065 LOG_WARN("MessageEditor", "Preview data is empty, skipping bitmap update");
1066 return;
1067 }
1068
1070 // CRITICAL: Use set_data() to properly update both data_ AND surface_
1071 // mutable_data() returns a reference but doesn't update the surface!
1073
1077 }
1078
1079 // Validate surface was updated
1081 LOG_ERROR("MessageEditor", "Bitmap surface is null after set_data()");
1082 return;
1083 }
1084
1085 // Queue texture update (or create if missing) so changes are visible
1086 const auto command = current_font_gfx16_bitmap_.texture()
1090
1091 LOG_DEBUG(
1092 "MessageEditor",
1093 "Updated message preview bitmap (size: %zu) and queued texture update",
1095 } else {
1096 // Create bitmap and queue texture creation with 8-bit indexed depth
1103
1104 LOG_INFO("MessageEditor",
1105 "Created message preview bitmap (%dx%d) with 8-bit depth and "
1106 "queued texture creation",
1108 }
1109}
1110
1111absl::Status MessageEditor::Save() {
1112 if (!rom_ || !rom_->is_loaded()) {
1113 return absl::FailedPreconditionError("ROM not loaded");
1114 }
1115 if (!current_parse_errors_.empty()) {
1116 return absl::FailedPreconditionError(absl::StrFormat(
1117 "Current message has parse errors: %s", current_parse_errors_.front()));
1118 }
1119
1120 return SaveDirtyDomains(/*include_font_widths=*/true,
1121 /*include_vanilla_messages=*/true,
1122 /*include_expanded_messages=*/true);
1123}
1124
1125absl::StatusOr<MessageEditor::SavePlan> MessageEditor::BuildSavePlan(
1126 bool include_font_widths, bool include_vanilla_messages,
1127 bool include_expanded_messages) const {
1128 if (!rom_ || !rom_->is_loaded()) {
1129 return absl::FailedPreconditionError("ROM not loaded");
1130 }
1131
1132 SavePlan plan;
1133
1134 if (include_font_widths && dirty_state_.font_widths) {
1135 plan.saves_font_widths = true;
1136 plan.writes.push_back(
1138 std::vector<uint8_t>(message_preview_.width_array.begin(),
1140 }
1141
1142 if (include_vanilla_messages && dirty_state_.vanilla_messages) {
1143 std::optional<size_t> expected_message_count;
1144 if (dependencies_.project &&
1146 const int manifest_count =
1148 if (manifest_count > 0) {
1149 expected_message_count = static_cast<size_t>(manifest_count);
1150 }
1151 }
1153 const VanillaMessageSavePlan vanilla_plan,
1154 BuildVanillaMessageSavePlan(list_of_texts_, expected_message_count));
1155
1156 plan.saves_vanilla_messages = true;
1157 for (const auto& write : vanilla_plan.writes()) {
1158 plan.writes.push_back(
1159 {SaveDomain::kVanillaMessages, write.start(), write.bytes()});
1160 }
1161 }
1162
1163 if (include_expanded_messages && dirty_state_.expanded_messages &&
1164 !expanded_messages_.empty()) {
1165 const int start = GetExpandedTextDataStart();
1166 const int end = GetExpandedTextDataEnd();
1167 if (start < 0 || end < start) {
1168 return absl::InvalidArgumentError("Invalid expanded message region");
1169 }
1170 if (static_cast<uint64_t>(start) >= rom_->size() ||
1171 static_cast<uint64_t>(end) >= rom_->size()) {
1172 return absl::OutOfRangeError(
1173 "Expanded message region is outside the ROM");
1174 }
1175
1176 const int64_t capacity = static_cast<int64_t>(end) - start + 1;
1177 std::vector<uint8_t> bytes;
1178 bytes.reserve(static_cast<size_t>(capacity));
1180
1181 for (size_t index = 0; index < expanded_messages_.size(); ++index) {
1182 const auto& message = expanded_messages_[index];
1183 auto parsed = ParseMessageToDataWithDiagnostics(message.RawString);
1184 if (!parsed.ok()) {
1185 return absl::InvalidArgumentError(
1186 absl::StrFormat("Expanded message %d is invalid: %s",
1187 static_cast<int>(index), parsed.errors.front()));
1188 }
1189 if (message.RawString.find("[BANK]") != std::string::npos) {
1190 return absl::InvalidArgumentError(absl::StrFormat(
1191 "Expanded message %d contains [BANK], which is only valid in the "
1192 "vanilla message stream",
1193 static_cast<int>(index)));
1194 }
1195
1196 const int64_t needed = static_cast<int64_t>(parsed.bytes.size()) + 1;
1197 if (static_cast<int64_t>(bytes.size()) + needed + 1 > capacity) {
1198 return absl::ResourceExhaustedError(absl::StrFormat(
1199 "Expanded message data exceeds bank boundary "
1200 "(at message %d, used=%d, needed=%d, capacity=%d, end=0x%06X)",
1201 static_cast<int>(index), static_cast<int>(bytes.size()),
1202 static_cast<int>(needed), static_cast<int>(capacity), end));
1203 }
1204
1205 plan.expanded_message_addresses.push_back(start +
1206 static_cast<int>(bytes.size()));
1207 bytes.insert(bytes.end(), parsed.bytes.begin(), parsed.bytes.end());
1208 bytes.push_back(kMessageTerminator);
1209 }
1210 bytes.push_back(0xFF);
1211
1212 plan.saves_expanded_messages = true;
1213 plan.writes.push_back({SaveDomain::kExpandedMessages,
1214 static_cast<uint32_t>(start), std::move(bytes)});
1215 }
1216
1217 for (const auto& write : plan.writes) {
1218 const uint64_t end =
1219 static_cast<uint64_t>(write.start) + write.bytes.size();
1220 if (end > rom_->size() || end > std::numeric_limits<uint32_t>::max()) {
1221 return absl::OutOfRangeError(absl::StrFormat(
1222 "Message save range [0x%06X, 0x%06llX) is outside the ROM",
1223 write.start, static_cast<unsigned long long>(end)));
1224 }
1225 }
1226
1227 return plan;
1228}
1229
1230absl::Status MessageEditor::ValidateSavePlan(const SavePlan& plan) const {
1231 if (!dependencies_.project ||
1232 !dependencies_.project->hack_manifest.loaded() || plan.writes.empty()) {
1233 return absl::OkStatus();
1234 }
1235
1236 std::vector<std::pair<uint32_t, uint32_t>> ranges;
1237 ranges.reserve(plan.writes.size());
1238 for (const auto& write : plan.writes) {
1239 ranges.emplace_back(write.start, write.end());
1240 }
1241
1242 const auto& yaze_project = *dependencies_.project;
1243 if (yaze_project.rom_metadata.write_policy ==
1246 absl::StrContains(yaze_project.hack_manifest.hack_name(),
1247 "Oracle of Secrets")) {
1248 return absl::PermissionDeniedError(
1249 "Oracle of Secrets expanded messages are owned by ASM. No message "
1250 "bytes were written; keep the draft in Yaze, edit Core/message.asm, "
1251 "rebuild Oracle of Secrets, and reopen the rebuilt ROM before "
1252 "retrying.");
1253 }
1254
1255 const auto status = ValidateHackManifestSaveConflicts(
1256 yaze_project.hack_manifest, yaze_project.rom_metadata.write_policy,
1257 ranges, "message data", "MessageEditor", dependencies_.toast_manager);
1258 return status;
1259}
1260
1261absl::Status MessageEditor::ApplySavePlan(const SavePlan& plan) {
1262 if (plan.writes.empty()) {
1263 return absl::OkStatus();
1264 }
1265
1267 for (const auto& write : plan.writes) {
1268 const char* label = "MessageData";
1269 switch (write.domain) {
1271 label = "MessageFontWidths";
1272 break;
1274 label = "VanillaMessageBank";
1275 break;
1277 label = "ExpandedMessageBank";
1278 break;
1279 }
1280 RETURN_IF_ERROR(fence.Allow(write.start, write.end(), label));
1281 }
1282
1283 ScopedRomTransaction transaction(*rom_);
1284 yaze::rom::ScopedWriteFence fence_scope(rom_, &fence);
1285 for (const auto& write : plan.writes) {
1287 rom_->WriteVector(static_cast<int>(write.start), write.bytes));
1288 }
1289 transaction.Commit();
1290
1291 if (plan.saves_expanded_messages) {
1292 for (size_t index = 0; index < expanded_messages_.size() &&
1293 index < plan.expanded_message_addresses.size();
1294 ++index) {
1295 expanded_messages_[index].Address =
1296 plan.expanded_message_addresses[index];
1297 }
1299 }
1300
1301 return absl::OkStatus();
1302}
1303
1304absl::Status MessageEditor::SaveDirtyDomains(bool include_font_widths,
1305 bool include_vanilla_messages,
1306 bool include_expanded_messages) {
1307 ASSIGN_OR_RETURN(const SavePlan plan,
1308 BuildSavePlan(include_font_widths, include_vanilla_messages,
1309 include_expanded_messages));
1312 RecordSavedDomains(plan);
1313 return absl::OkStatus();
1314}
1315
1317 return SaveDirtyDomains(/*include_font_widths=*/false,
1318 /*include_vanilla_messages=*/false,
1319 /*include_expanded_messages=*/true);
1320}
1321
1324 return absl::FailedPreconditionError(
1325 "Message save transaction is already active");
1326 }
1331 for (const auto& message : expanded_messages_) {
1332 transaction_expanded_address_snapshot_.push_back(message.Address);
1333 }
1335 return absl::OkStatus();
1336}
1337
1340 return;
1341 }
1343 for (size_t index = 0; index < expanded_messages_.size() &&
1345 ++index) {
1346 expanded_messages_[index].Address =
1348 }
1354}
1355
1366
1368 bool* dirty = nullptr;
1369 bool* transaction_saved = nullptr;
1370 switch (domain) {
1372 dirty = &dirty_state_.font_widths;
1373 transaction_saved = &transaction_saved_domains_.font_widths;
1374 break;
1377 transaction_saved = &transaction_saved_domains_.vanilla_messages;
1378 break;
1381 transaction_saved = &transaction_saved_domains_.expanded_messages;
1382 break;
1383 }
1384 *dirty = true;
1386 *transaction_saved = false;
1387 }
1388 if (rom_) {
1389 rom_->set_dirty(true);
1390 }
1391}
1392
1397 ClearSavedDomains(saved);
1398 return;
1399 }
1400 transaction_saved_domains_.font_widths |= saved.font_widths;
1401 transaction_saved_domains_.vanilla_messages |= saved.vanilla_messages;
1402 transaction_saved_domains_.expanded_messages |= saved.expanded_messages;
1403}
1404
1406 if (saved.font_widths) {
1407 dirty_state_.font_widths = false;
1408 }
1409 if (saved.vanilla_messages) {
1411 }
1412 if (saved.expanded_messages) {
1414 }
1415}
1416
1424
1426 if (!rom_ || !rom_->is_loaded()) {
1427 return absl::FailedPreconditionError("ROM not loaded");
1428 }
1429
1432
1433 expanded_messages_.clear();
1436 std::min(GetExpandedTextDataEnd(), static_cast<int>(rom_->size()) - 1));
1437
1438 if (expanded_messages_.empty()) {
1439 return absl::NotFoundError(
1440 "No expanded messages found in ROM at expanded text region");
1441 }
1442
1443 // Parse the expanded messages and append to the unified list
1444 auto parsed_expanded =
1446 for (const auto& msg : expanded_messages_) {
1447 if (msg.ID >= 0 && msg.ID < static_cast<int>(parsed_expanded.size())) {
1448 parsed_messages_.push_back(parsed_expanded[msg.ID]);
1449 }
1450 }
1451
1452 expanded_message_path_ = "(ROM)";
1454 return absl::OkStatus();
1455}
1456
1458 if (expanded_messages_.empty())
1459 return 0;
1460 int total = 0;
1461 for (const auto& msg : expanded_messages_) {
1462 total += static_cast<int>(msg.Data.size()) + 1; // +1 for 0x7F
1463 }
1464 total += 1; // +1 for final 0xFF
1465 return total;
1466}
1467
1468absl::Status MessageEditor::Cut() {
1469 // Ensure that text is currently selected in the text box.
1470 if (!message_text_box_.text.empty()) {
1471 // Cut the selected text in the control and paste it into the Clipboard.
1473 }
1474 return absl::OkStatus();
1475}
1476
1477absl::Status MessageEditor::Paste() {
1478 // Determine if there is any text in the Clipboard to paste into the
1479 if (ImGui::GetClipboardText() != nullptr) {
1480 // Paste the text from the Clipboard into the text box.
1482 }
1483 return absl::OkStatus();
1484}
1485
1486absl::Status MessageEditor::Copy() {
1487 // Ensure that text is selected in the text box.
1489 // Copy the selected text to the Clipboard.
1491 }
1492 return absl::OkStatus();
1493}
1494
1496 if (pending_undo_before_.has_value()) {
1497 // If we're still editing the same message, keep the existing "before"
1498 // snapshot so the entire edit session becomes a single undo step.
1499 if (pending_undo_before_->message_index == current_message_index_ &&
1501 return;
1502 }
1504 }
1505
1506 // Capture current state as "before"
1507 int parsed_index = current_message_index_;
1510 }
1511 std::string text;
1512 if (parsed_index >= 0 &&
1513 parsed_index < static_cast<int>(parsed_messages_.size())) {
1514 text = parsed_messages_[parsed_index];
1515 }
1519}
1520
1522 if (!pending_undo_before_.has_value())
1523 return;
1524
1525 // The "after" snapshot must correspond to the same message as the pending
1526 // "before", even if the user navigated to a different message in the UI.
1527 const int message_index = pending_undo_before_->message_index;
1528 const bool is_expanded = pending_undo_before_->is_expanded;
1529
1530 MessageData after_message;
1531 if (is_expanded) {
1532 if (message_index < 0 ||
1533 message_index >= static_cast<int>(expanded_messages_.size())) {
1534 pending_undo_before_.reset();
1535 return;
1536 }
1537 after_message = expanded_messages_[message_index];
1538 } else {
1539 if (message_index < 0 ||
1540 message_index >= static_cast<int>(list_of_texts_.size())) {
1541 pending_undo_before_.reset();
1542 return;
1543 }
1544 after_message = list_of_texts_[message_index];
1545 }
1546
1547 int parsed_index = message_index;
1548 if (is_expanded) {
1549 parsed_index = expanded_message_base_id_ + message_index;
1550 }
1551 std::string text;
1552 if (parsed_index >= 0 &&
1553 parsed_index < static_cast<int>(parsed_messages_.size())) {
1554 text = parsed_messages_[parsed_index];
1555 }
1556 MessageSnapshot after{std::move(after_message), std::move(text),
1557 message_index, is_expanded};
1558
1559 undo_manager_.Push(std::make_unique<MessageEditAction>(
1560 std::move(*pending_undo_before_), std::move(after),
1561 [this](const MessageSnapshot& s) { ApplySnapshot(s); }));
1562 pending_undo_before_.reset();
1563}
1564
1566 current_message_ = snapshot.message;
1570 const auto diagnostics =
1572 current_parse_errors_ = diagnostics.errors;
1573 current_parse_warnings_ = diagnostics.warnings;
1574
1575 int parsed_index = snapshot.message_index;
1576 if (snapshot.is_expanded) {
1577 parsed_index = expanded_message_base_id_ + snapshot.message_index;
1578 }
1579 if (parsed_index >= 0 &&
1580 parsed_index < static_cast<int>(parsed_messages_.size())) {
1581 parsed_messages_[parsed_index] = snapshot.parsed_text;
1582 }
1583
1584 if (snapshot.is_expanded) {
1585 if (snapshot.message_index >= 0 &&
1586 snapshot.message_index < static_cast<int>(expanded_messages_.size())) {
1587 expanded_messages_[snapshot.message_index] = snapshot.message;
1588 }
1589 } else {
1590 if (snapshot.message_index >= 0 &&
1591 snapshot.message_index < static_cast<int>(list_of_texts_.size())) {
1592 list_of_texts_[snapshot.message_index] = snapshot.message;
1593 }
1594 }
1595
1596 if (rom_) {
1597 rom_->set_dirty(true);
1598 }
1602}
1603
1604absl::Status MessageEditor::Undo() {
1606 return undo_manager_.Undo();
1607}
1608
1609absl::Status MessageEditor::Redo() {
1610 return undo_manager_.Redo();
1611}
1612
1614 // Determine if any text is selected in the TextBox control.
1616 // clear all of the text in the textbox.
1618 }
1619}
1620
1622 // Determine if any text is selected in the TextBox control.
1624 // Select all text in the text box.
1626
1627 // Move the cursor to the text box.
1629 }
1630}
1631
1632absl::Status MessageEditor::Find() {
1633 if (ImGui::Begin("Find & Replace", nullptr,
1634 ImGuiWindowFlags_AlwaysAutoResize)) {
1635 static char find_text[256] = "";
1636 static char replace_text[256] = "";
1637 ImGui::InputText(tr("Search"), find_text, IM_ARRAYSIZE(find_text));
1638 ImGui::InputText(tr("Replace with"), replace_text,
1639 IM_ARRAYSIZE(replace_text));
1640
1641 if (ImGui::Button(tr("Find Next"))) {
1642 search_text_ = find_text;
1643 replace_status_.clear();
1644 }
1645
1646 ImGui::SameLine();
1647 if (ImGui::Button(tr("Find All"))) {
1648 search_text_ = find_text;
1649 replace_status_.clear();
1650 }
1651
1652 ImGui::SameLine();
1653 if (ImGui::Button(tr("Replace"))) {
1654 search_text_ = find_text;
1655 replace_text_ = replace_text;
1656 int count = ReplaceCurrentMatch();
1657 if (count > 0) {
1658 replace_status_ = "Replaced 1 occurrence";
1659 replace_status_error_ = false;
1660 } else {
1661 replace_status_ = "No match found in current message";
1662 replace_status_error_ = true;
1663 }
1664 }
1665
1666 ImGui::SameLine();
1667 if (ImGui::Button(tr("Replace All"))) {
1668 search_text_ = find_text;
1669 replace_text_ = replace_text;
1670 int count = ReplaceAllMatches();
1671 if (count >= 0) {
1672 replace_status_ = absl::StrFormat("Replaced %d occurrence%s", count,
1673 count == 1 ? "" : "s");
1674 replace_status_error_ = (count == 0);
1675 }
1676 }
1677
1678 ImGui::Checkbox(tr("Case Sensitive"), &case_sensitive_);
1679 ImGui::SameLine();
1680 ImGui::Checkbox(tr("Match Whole Word"), &match_whole_word_);
1681
1682 if (!replace_status_.empty()) {
1683 ImVec4 color =
1685 ImGui::TextColored(color, "%s", replace_status_.c_str());
1686 }
1687 }
1688 ImGui::End();
1689
1690 return absl::OkStatus();
1691}
1692
1694 if (search_text_.empty())
1695 return 0;
1696
1697 std::string& text = message_text_box_.text;
1698 std::string search = search_text_;
1699 std::string source = text;
1700
1701 if (!case_sensitive_) {
1702 std::transform(search.begin(), search.end(), search.begin(), ::tolower);
1703 std::transform(source.begin(), source.end(), source.begin(), ::tolower);
1704 }
1705
1706 size_t pos = source.find(search);
1707 if (pos == std::string::npos)
1708 return 0;
1709
1710 // Check whole word boundary if required
1711 if (match_whole_word_) {
1712 bool start_ok = (pos == 0 || !std::isalnum(source[pos - 1]));
1713 bool end_ok = (pos + search.size() >= source.size() ||
1714 !std::isalnum(source[pos + search.size()]));
1715 if (!start_ok || !end_ok) {
1716 // Search for a whole-word match further in the string
1717 while (pos != std::string::npos) {
1718 start_ok = (pos == 0 || !std::isalnum(source[pos - 1]));
1719 end_ok = (pos + search.size() >= source.size() ||
1720 !std::isalnum(source[pos + search.size()]));
1721 if (start_ok && end_ok)
1722 break;
1723 pos = source.find(search, pos + 1);
1724 }
1725 if (pos == std::string::npos)
1726 return 0;
1727 }
1728 }
1729
1730 // Perform the replacement in the original (case-preserving) text
1731 text.replace(pos, search_text_.size(), replace_text_);
1734 return 1;
1735}
1736
1738 if (search_text_.empty()) {
1739 return 0;
1740 }
1741 if (!current_parse_errors_.empty()) {
1743 "Replace All blocked: resolve the current message parse errors first";
1744 replace_status_error_ = true;
1745 return -1;
1746 }
1747
1748 auto replace_in_text = [&](std::string& text) -> int {
1749 int count = 0;
1750 std::string search = search_text_;
1751
1752 if (!case_sensitive_) {
1753 std::transform(search.begin(), search.end(), search.begin(), ::tolower);
1754 }
1755
1756 size_t pos = 0;
1757 while (pos < text.size()) {
1758 std::string source = text;
1759 if (!case_sensitive_) {
1760 std::transform(source.begin(), source.end(), source.begin(), ::tolower);
1761 }
1762
1763 size_t found = source.find(search, pos);
1764 if (found == std::string::npos)
1765 break;
1766
1767 if (match_whole_word_) {
1768 bool start_ok = (found == 0 || !std::isalnum(source[found - 1]));
1769 bool end_ok = (found + search.size() >= source.size() ||
1770 !std::isalnum(source[found + search.size()]));
1771 if (!start_ok || !end_ok) {
1772 pos = found + 1;
1773 continue;
1774 }
1775 }
1776
1777 text.replace(found, search_text_.size(), replace_text_);
1778 pos = found + replace_text_.size();
1779 count++;
1780 }
1781 return count;
1782 };
1783
1784 struct PlannedReplacement {
1785 int message_index;
1786 bool is_expanded;
1787 std::string text;
1788 int count;
1789 };
1790 std::vector<PlannedReplacement> plan;
1791
1792 const auto plan_replacements = [&](const std::vector<MessageData>& messages,
1793 bool is_expanded) -> bool {
1794 for (size_t i = 0; i < messages.size(); ++i) {
1795 const int parsed_index =
1796 (is_expanded ? expanded_message_base_id_ : 0) + static_cast<int>(i);
1797 if (parsed_index < 0 ||
1798 parsed_index >= static_cast<int>(parsed_messages_.size())) {
1799 continue;
1800 }
1801
1802 std::string text = parsed_messages_[parsed_index];
1803 const int count = replace_in_text(text);
1804 if (count == 0) {
1805 continue;
1806 }
1807
1808 const auto parsed = ParseMessageToDataWithDiagnostics(text);
1809 if (!parsed.ok() ||
1810 (is_expanded && text.find("[BANK]") != std::string::npos)) {
1811 const std::string error =
1812 !parsed.ok() ? parsed.errors.front()
1813 : "[BANK] is not valid in expanded messages";
1814 replace_status_ = absl::StrFormat(
1815 "Replace All aborted at %s message %d: %s",
1816 is_expanded ? "expanded" : "vanilla", static_cast<int>(i), error);
1817 replace_status_error_ = true;
1818 return false;
1819 }
1820
1821 plan.push_back(
1822 {static_cast<int>(i), is_expanded, std::move(text), count});
1823 }
1824 return true;
1825 };
1826
1827 // Validate the complete batch before changing any message or undo state.
1828 if (!plan_replacements(list_of_texts_, false) ||
1829 !plan_replacements(expanded_messages_, true)) {
1830 return -1;
1831 }
1832
1833 const int previous_index = current_message_index_;
1834 const bool previous_expanded = current_message_is_expanded_;
1835 int total_replacements = 0;
1836
1837 for (const auto& replacement : plan) {
1838 current_message_ = replacement.is_expanded
1839 ? expanded_messages_[replacement.message_index]
1840 : list_of_texts_[replacement.message_index];
1841 current_message_index_ = replacement.message_index;
1842 current_message_is_expanded_ = replacement.is_expanded;
1843 message_text_box_.text = replacement.text;
1844 UpdateCurrentMessageFromText(replacement.text);
1846 total_replacements += replacement.count;
1847 }
1848
1849 current_message_index_ = previous_index;
1850 current_message_is_expanded_ = previous_expanded;
1851
1852 // Refresh the current message's text box from updated data
1853 int current_parsed_idx = current_message_index_;
1856 }
1857 if (current_parsed_idx >= 0 &&
1858 current_parsed_idx < static_cast<int>(parsed_messages_.size())) {
1859 message_text_box_.text = parsed_messages_[current_parsed_idx];
1860 }
1861
1862 // Refresh current_message_ to reflect replacements
1864 if (current_message_index_ >= 0 &&
1865 current_message_index_ < static_cast<int>(expanded_messages_.size())) {
1867 }
1868 } else {
1869 if (current_message_index_ >= 0 &&
1870 current_message_index_ < static_cast<int>(list_of_texts_.size())) {
1872 }
1873 }
1874
1875 const auto current_diagnostics =
1877 current_parse_errors_ = current_diagnostics.errors;
1878 current_parse_warnings_ = current_diagnostics.warnings;
1879
1880 return total_replacements;
1881}
1882
1883} // namespace editor
1884} // namespace yaze
auto begin()
Definition rom.h:171
void set_dirty(bool dirty)
Definition rom.h:157
auto mutable_data()
Definition rom.h:170
absl::Status WriteVector(int addr, std::vector< uint8_t > data)
Definition rom.cc:703
auto data() const
Definition rom.h:169
auto size() const
Definition rom.h:168
bool is_loaded() const
Definition rom.h:155
const MessageLayout & message_layout() const
bool loaded() const
Check if the manifest has been loaded.
virtual void SetGameData(zelda3::GameData *game_data)
Definition editor.h:255
UndoManager undo_manager_
Definition editor.h:334
zelda3::GameData * game_data() const
Definition editor.h:320
EditorDependencies dependencies_
Definition editor.h:333
std::vector< std::string > parsed_messages_
absl::Status Copy() override
void CommitSaveTransaction() override
std::vector< MessageData > expanded_messages_
absl::Status Find() override
void RollbackSaveTransaction() override
absl::Status Update() override
void ClearSavedDomains(const DirtyState &saved)
absl::Status LoadExpandedMessagesFromRom()
void RecordSavedDomains(const SavePlan &plan)
void UpdateCurrentMessageFromText(const std::string &text)
absl::Status Paste() override
void ApplySnapshot(const MessageSnapshot &snapshot)
absl::StatusOr< SavePlan > BuildSavePlan(bool include_font_widths, bool include_vanilla_messages, bool include_expanded_messages) const
std::vector< int > transaction_expanded_address_snapshot_
void MarkDomainDirty(SaveDomain domain)
void RefreshFontAtlasBitmap(const std::vector< uint8_t > &font_data)
std::vector< std::string > current_parse_errors_
absl::Status Undo() override
absl::Status Load() override
std::vector< std::string > current_parse_warnings_
std::array< uint8_t, 0x4000 > raw_font_gfx_data_
gfx::SnesPalette BuildFallbackFontPalette() const
absl::Status SaveDirtyDomains(bool include_font_widths, bool include_vanilla_messages, bool include_expanded_messages)
absl::Status Cut() override
std::optional< MessageSnapshot > pending_undo_before_
absl::Status BeginSaveTransaction() override
std::vector< MessageData > list_of_texts_
bool OpenMessageById(int display_id)
gfx::SnesPalette font_preview_colors_
absl::Status Redo() override
void ImportMessageBundleFromFile(const std::string &path)
absl::Status ValidateSavePlan(const SavePlan &plan) const
absl::Status ApplySavePlan(const SavePlan &plan)
absl::Status Save() override
void SetGameData(zelda3::GameData *game_data) override
void Push(std::unique_ptr< UndoAction > action)
absl::Status Redo()
Redo the top action. Returns error if stack is empty.
absl::Status Undo()
Undo the top action. Returns error if stack is empty.
const std::vector< VanillaMessageWrite > & writes() const
bool OpenWindow(size_t session_id, const std::string &base_window_id)
void QueueTextureCommand(TextureCommandType type, Bitmap *bitmap)
Definition arena.cc:36
static Arena & Get()
Definition arena.cc:21
Represents a bitmap image optimized for SNES ROM hacking.
Definition bitmap.h:67
const SnesPalette & palette() const
Definition bitmap.h:389
void Create(int width, int height, int depth, std::span< uint8_t > data)
Create a bitmap with the given dimensions and data.
Definition bitmap.cc:202
TextureHandle texture() const
Definition bitmap.h:401
bool is_active() const
Definition bitmap.h:405
SnesPalette * mutable_palette()
Definition bitmap.h:390
void set_data(const std::vector< uint8_t > &data)
Definition bitmap.cc:864
void SetPalette(const SnesPalette &palette)
Set the palette for the bitmap using SNES palette format.
Definition bitmap.cc:394
SDL_Surface * surface() const
Definition bitmap.h:400
RAII timer for automatic timing management.
Represents a palette of colors for the Super Nintendo Entertainment System (SNES).
void DrawBitmap(Bitmap &bitmap, int border_offset, float scale)
Definition canvas.cc:1173
void DrawContextMenu()
Definition canvas.cc:703
bool DrawTileSelector(int size, int size_y=0)
Definition canvas.cc:1109
auto canvas_size() const
Definition canvas.h:359
auto zero_point() const
Definition canvas.h:350
CanvasConfig & GetConfig()
Definition canvas.h:229
void DrawBackground(ImVec2 canvas_size=ImVec2(0, 0))
Definition canvas.cc:613
void DrawGrid(float grid_step=64.0f, int tile_id_offset=8)
Definition canvas.cc:1495
absl::Status Allow(uint32_t start, uint32_t end, std::string_view label)
Definition write_fence.h:32
static std::string ShowSaveFileDialog(const std::string &default_name="", const std::string &default_extension="")
ShowSaveFileDialog opens a save file dialog and returns the selected filepath. Uses global feature fl...
static std::string ShowOpenFileDialog()
ShowOpenFileDialog opens a file dialog and returns the selected filepath. Uses global feature flag to...
#define LOG_DEBUG(category, format,...)
Definition log.h:103
#define LOG_ERROR(category, format,...)
Definition log.h:109
#define LOG_WARN(category, format,...)
Definition log.h:107
#define LOG_INFO(category, format,...)
Definition log.h:105
#define PRINT_IF_ERROR(expression)
Definition macro.h:28
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
constexpr int kCharactersWidth
int GetExpandedTextDataStart()
constexpr uint8_t kScrollVertical
std::optional< ResolvedMessageId > ResolveMessageDisplayId(int display_id, int vanilla_count, int expanded_base_id, int expanded_count)
absl::Status ValidateHackManifestSaveConflicts(const core::HackManifest &manifest, project::RomWritePolicy write_policy, const std::vector< std::pair< uint32_t, uint32_t > > &ranges, absl::string_view save_scope, const char *log_tag, ToastManager *toast_manager)
absl::Status LoadExpandedMessages(std::string &expanded_message_path, std::vector< std::string > &parsed_messages, std::vector< MessageData > &expanded_messages, std::vector< DictionaryEntry > &dictionary)
constexpr uint8_t kLine1
constexpr int kTextData
std::string MessageBankToString(MessageBank bank)
constexpr int kCurrentMessageWidth
constexpr int kCurrentMessageHeight
constexpr uint8_t kLine2
constexpr int kGfxFont
absl::StatusOr< std::vector< MessageBundleEntry > > LoadMessageBundleFromJson(const std::string &path)
constexpr int kFontGfxMessageSize
std::vector< MessageData > ReadAllTextData(uint8_t *rom, int pos, int max_pos, bool allow_bank_switch)
std::vector< std::string > ParseMessageData(std::vector< MessageData > &message_data, const std::vector< DictionaryEntry > &dictionary_entries)
constexpr uint8_t kMessageTerminator
constexpr int kFontGfxMessageDepth
std::vector< DictionaryEntry > BuildDictionaryEntries(Rom *rom)
absl::Status ExportMessagesToJson(const std::string &path, const std::vector< MessageData > &messages)
constexpr uint8_t kWidthArraySize
absl::Status ExportMessageBundleToJson(const std::string &path, const std::vector< MessageData > &vanilla, const std::vector< MessageData > &expanded)
constexpr ImGuiTableFlags kMessageTableFlags
absl::StatusOr< VanillaMessageSavePlan > BuildVanillaMessageSavePlan(const std::vector< MessageData > &messages, std::optional< size_t > expected_message_count)
std::vector< MessageData > ReadExpandedTextData(uint8_t *rom, int pos)
MessageParseResult ParseMessageToDataWithDiagnostics(std::string_view str)
int GetExpandedTextDataEnd()
std::vector< std::string > ValidateMessageLineWidths(const std::string &message)
constexpr uint8_t kLine3
std::vector< uint8_t > SnesTo8bppSheet(std::span< const uint8_t > sheet, int bpp, int num_sheets)
Definition snes_tile.cc:132
void EndCanvas(Canvas &canvas)
void BeginPadding(int i)
Definition style.cc:277
ImVec4 GetSuccessColor()
Definition ui_helpers.cc:49
void BeginCanvas(Canvas &canvas, ImVec2 child_size)
void EndNoPadding()
Definition style.cc:289
void MemoryEditorPopup(const std::string &label, std::span< uint8_t > memory)
Definition input.cc:816
void EndPadding()
Definition style.cc:281
void BeginNoPadding()
Definition style.cc:285
ImVec4 GetErrorColor()
Definition ui_helpers.cc:59
ImVec4 GetWarningColor()
Definition ui_helpers.cc:54
IMGUI_API bool DisplayPalette(gfx::SnesPalette &palette, bool loaded)
Definition color.cc:239
ImVec4 GetInfoColor()
Definition ui_helpers.cc:64
bool InputHexByte(const char *label, uint8_t *data, float input_width, bool no_step)
Definition input.cc:506
std::string HexWord(uint16_t word, HexStringParams params)
Definition hex.cc:41
std::string HexLong(uint32_t dword, HexStringParams params)
Definition hex.cc:52
absl::StatusOr< gfx::Bitmap > LoadFontGraphics(const Rom &rom)
Loads font graphics from ROM.
Definition game_data.cc:609
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
project::YazeProject * project
Definition editor.h:173
WorkspaceWindowManager * window_manager
Definition editor.h:181
std::vector< uint8_t > Data
std::vector< PlannedWrite > writes
std::vector< uint8_t > current_preview_data_
void DrawMessagePreview(const MessageData &message)
std::array< uint8_t, kWidthArraySize > width_array
std::vector< uint8_t > font_gfx16_data_2_
std::vector< uint8_t > font_gfx16_data_
std::vector< int > scroll_marker_lines
std::vector< DictionaryEntry > all_dictionaries_
auto palette(int i) const
void SelectAll()
Definition style.h:103
std::string text
Definition style.h:58
int selection_length
Definition style.h:63
core::HackManifest hack_manifest
Definition project.h:212
gfx::PaletteGroupMap palette_groups
Definition game_data.h:92