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
39namespace {
40std::string DisplayTextOverflowError(int pos, bool bank) {
41 int space = bank ? kTextDataEnd - kTextData : kTextData2End - kTextData2;
42 std::string bankSTR = bank ? "1st" : "2nd";
43 std::string posSTR =
44 bank ? absl::StrFormat("%X4", pos & 0xFFFF)
45 : absl::StrFormat("%X4", (pos - kTextData2) & 0xFFFF);
46 std::string message = absl::StrFormat(
47 "There is too much text data in the %s block to save.\n"
48 "Available: %X4 | Used: %s",
49 bankSTR, space, posSTR);
50 return message;
51}
52} // namespace
53
54using ImGui::BeginChild;
55using ImGui::BeginTable;
56using ImGui::Button;
57using ImGui::EndChild;
58using ImGui::EndTable;
59using ImGui::InputTextMultiline;
60using ImGui::PopID;
61using ImGui::PushID;
62using ImGui::SameLine;
63using ImGui::Separator;
64using ImGui::TableHeadersRow;
65using ImGui::TableNextColumn;
66using ImGui::TableSetupColumn;
67using ImGui::Text;
68using ImGui::TextWrapped;
69
70constexpr ImGuiTableFlags kMessageTableFlags = ImGuiTableFlags_Hideable |
71 ImGuiTableFlags_Borders |
72 ImGuiTableFlags_Resizable;
73
76 dirty_state_ = {};
81 // Register panels with WorkspaceWindowManager (dependency injection)
83 return;
84
85 auto* window_manager = dependencies_.window_manager;
86 const size_t session_id = dependencies_.session_id;
87
88 // Register WindowContent implementations (they provide both metadata and drawing)
89 window_manager->RegisterWindowContent(
90 std::make_unique<MessageListPanel>([this]() { DrawMessageList(); }));
91 window_manager->RegisterWindowContent(
92 std::make_unique<MessageEditorPanel>([this]() { DrawCurrentMessage(); }));
93 window_manager->RegisterWindowContent(
94 std::make_unique<FontAtlasPanel>([this]() {
97 }));
98 window_manager->RegisterWindowContent(
99 std::make_unique<DictionaryPanel>([this]() {
103 }));
104
105 // Show message list by default
106 window_manager->OpenWindow(session_id, "message.message_list");
107
108 for (int i = 0; i < kWidthArraySize; i++) {
110 }
111
113 list_of_texts_ = ReadAllTextData(rom()->mutable_data());
114 LOG_INFO("MessageEditor", "Loaded %zu messages from ROM",
115 list_of_texts_.size());
116
121 }
124
126
127 if (!list_of_texts_.empty()) {
128 // Default to message 1 if available, otherwise 0
129 size_t default_idx = list_of_texts_.size() > 1 ? 1 : 0;
130 current_message_ = list_of_texts_[default_idx];
134 current_parse_errors_.clear();
137 } else {
138 LOG_ERROR("MessageEditor", "No messages found in ROM!");
139 }
140}
141
142bool MessageEditor::OpenMessageById(int display_id) {
143 // Do not discard an invalid in-progress draft by navigating away. The user
144 // can correct it or undo it before selecting another message.
145 if (!current_parse_errors_.empty()) {
146 return false;
147 }
148
149 const int vanilla_count = static_cast<int>(list_of_texts_.size());
150 const int expanded_base_id = expanded_message_base_id_;
151
152 int expanded_count = static_cast<int>(expanded_messages_.size());
153 auto resolved = ResolveMessageDisplayId(display_id, vanilla_count,
154 expanded_base_id, expanded_count);
155
156 // Convenience: if an expanded ID is requested but we haven't loaded expanded
157 // messages yet, try loading from ROM once.
158 if (!resolved.has_value() && expanded_count == 0 &&
159 display_id >= expanded_base_id && rom_ && rom_->is_loaded()) {
160 const int start = GetExpandedTextDataStart();
161 const int end = GetExpandedTextDataEnd();
162 const size_t rom_size = rom_->size();
163 if (start >= 0 && end >= start && static_cast<size_t>(end) < rom_size) {
164 const auto status = LoadExpandedMessagesFromRom();
165 if (!status.ok()) {
166 LOG_DEBUG("MessageEditor",
167 "OpenMessageById: expanded load skipped/failed: %s",
168 std::string(status.message()).c_str());
169 }
170 expanded_count = static_cast<int>(expanded_messages_.size());
171 resolved = ResolveMessageDisplayId(display_id, vanilla_count,
172 expanded_base_id, expanded_count);
173 } else {
174 LOG_DEBUG("MessageEditor",
175 "OpenMessageById: expanded region out of bounds (0x%X-0x%X, "
176 "rom=0x%zX)",
177 start, end, rom_size);
178 }
179 }
180
181 if (!resolved.has_value()) {
182 return false;
183 }
184
186 const size_t session_id = dependencies_.session_id;
188 "message.message_list");
190 "message.message_editor");
191 }
192
193 if (!resolved->is_expanded) {
194 const int idx = resolved->index;
195 if (idx < 0 || idx >= vanilla_count) {
196 return false;
197 }
198
199 const auto& message = list_of_texts_[idx];
200 current_message_ = message;
201 current_message_index_ = message.ID;
203
204 const int parsed_idx = resolved->display_id;
205 if (parsed_idx >= 0 &&
206 parsed_idx < static_cast<int>(parsed_messages_.size())) {
208 } else {
209 message_text_box_.text.clear();
210 }
211
212 current_parse_errors_.clear();
215 return true;
216 }
217
218 // Expanded message.
219 const int idx = resolved->index;
220 if (idx < 0 || idx >= expanded_count) {
221 return false;
222 }
223
224 const auto& message = expanded_messages_[idx];
225 current_message_ = message;
226 current_message_index_ = message.ID;
228
229 const int parsed_idx = resolved->display_id;
230 if (parsed_idx >= 0 &&
231 parsed_idx < static_cast<int>(parsed_messages_.size())) {
233 } else {
234 message_text_box_.text.clear();
235 }
236
237 current_parse_errors_.clear();
240 return true;
241}
242
244 int base_id = static_cast<int>(list_of_texts_.size());
246 const auto& layout = dependencies_.project->hack_manifest.message_layout();
247 if (layout.first_expanded_id != 0) {
248 base_id = static_cast<int>(layout.first_expanded_id);
249 }
250 }
251
252 // Never allow the expanded base to precede the vanilla message count; this
253 // prevents truncating/overlapping IDs when the manifest is missing/mistyped.
254 base_id = std::max(base_id, static_cast<int>(list_of_texts_.size()));
255 return base_id;
256}
257
259 if (game_data() && !game_data()->palette_groups.hud.empty()) {
261 }
262
265 }
266}
267
269 std::vector<gfx::SnesColor> colors;
270 colors.reserve(16);
271 for (int i = 0; i < 16; ++i) {
272 const float value = static_cast<float>(i) / 15.0f;
273 colors.emplace_back(ImVec4(value, value, value, 1.0f));
274 }
275
276 if (!colors.empty()) {
277 colors[0].set_transparent(true);
278 }
279
280 return gfx::SnesPalette(colors);
281}
282
285 if (!rom() || !rom()->is_loaded()) {
286 LOG_WARN("MessageEditor", "ROM not loaded - skipping font graphics load");
287 return;
288 }
289
290 std::fill(raw_font_gfx_data_.begin(), raw_font_gfx_data_.end(), 0);
291
292 const size_t rom_size = rom()->size();
293 if (rom_size > static_cast<size_t>(kGfxFont)) {
294 const size_t available = std::min(raw_font_gfx_data_.size(),
295 rom_size - static_cast<size_t>(kGfxFont));
296 std::copy_n(rom()->data() + kGfxFont, available,
298 if (available < raw_font_gfx_data_.size()) {
299 LOG_WARN("MessageEditor",
300 "Font graphics truncated (ROM size %zu, read %zu bytes)",
301 rom_size, available);
302 }
303 } else {
304 LOG_WARN("MessageEditor",
305 "ROM size %zu too small for font graphics offset 0x%X", rom_size,
306 kGfxFont);
307 }
308
310 gfx::SnesTo8bppSheet(raw_font_gfx_data_, /*bpp=*/2, /*num_sheets=*/2);
311
312 auto load_font = zelda3::LoadFontGraphics(*rom());
313 if (load_font.ok()) {
314 message_preview_.font_gfx16_data_2_ = load_font.value().vector();
315 } else {
316 const std::string error_message(load_font.status().message());
317 LOG_WARN("MessageEditor", "LoadFontGraphics failed: %s",
318 error_message.c_str());
319 }
320
321 const auto& font_data = !message_preview_.font_gfx16_data_.empty()
324 RefreshFontAtlasBitmap(font_data);
326}
327
329 const std::vector<uint8_t>& font_data) {
330 if (font_data.empty()) {
331 LOG_WARN("MessageEditor", "Font graphics data missing - atlas stays empty");
332 return;
333 }
334
335 const int atlas_width = kFontGfxMessageSize;
336 const size_t row_count = (font_data.size() + atlas_width - 1) / atlas_width;
337 const int atlas_height = static_cast<int>(std::max<size_t>(1, row_count));
338
339 const size_t expected_size = static_cast<size_t>(atlas_width) * atlas_height;
340 std::vector<uint8_t> padded(font_data.begin(), font_data.end());
341 if (padded.size() < expected_size) {
342 padded.resize(expected_size, 0);
343 } else if (padded.size() > expected_size) {
344 padded.resize(expected_size);
345 }
346
347 font_gfx_bitmap_.Create(atlas_width, atlas_height, kFontGfxMessageDepth,
348 padded);
351 }
354}
355
356absl::Status MessageEditor::Load() {
357 gfx::ScopedTimer timer("MessageEditor::Load");
358 return absl::OkStatus();
359}
360
361absl::Status MessageEditor::Update() {
362 // Panel drawing is handled centrally by WorkspaceWindowManager::DrawAllVisiblePanels()
363 // via the WindowContent implementations registered in Initialize().
364 // No local drawing needed here.
365 return absl::OkStatus();
366}
367
372
375
377 return;
378 }
379
380 auto queue_refresh = [](gfx::Bitmap& bitmap) {
381 if (!bitmap.is_active()) {
382 return;
383 }
384 const auto command = bitmap.texture()
387 gfx::Arena::Get().QueueTextureCommand(command, &bitmap);
388 };
389
391 queue_refresh(font_gfx_bitmap_);
392
395 queue_refresh(current_font_gfx16_bitmap_);
396 }
397}
398
417
420 if (BeginChild("##MessagesList", ImVec2(0, 0), true,
421 ImGuiWindowFlags_AlwaysVerticalScrollbar)) {
423 if (ImGui::Button(tr("Import Bundle"))) {
425 if (!path.empty()) {
427 }
428 }
429 ImGui::SameLine();
430 if (ImGui::Button(tr("Export Bundle"))) {
432 if (!path.empty()) {
433 auto status =
435 if (!status.ok()) {
437 absl::StrFormat("Export failed: %s", status.message());
439 } else {
440 message_bundle_status_ = absl::StrFormat("Exported bundle: %s", path);
442 }
443 }
444 }
445 if (!message_bundle_status_.empty()) {
448 ImGui::TextColored(color, "%s", message_bundle_status_.c_str());
449 }
450 ImGui::Separator();
451 if (BeginTable("##MessagesTable", 4, kMessageTableFlags)) {
452 TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 50);
453 TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 80);
454 TableSetupColumn("Contents", ImGuiTableColumnFlags_WidthStretch);
455 TableSetupColumn("Address", ImGuiTableColumnFlags_WidthFixed, 100);
456
457 TableHeadersRow();
458
459 // Calculate total rows for clipper
460 const int vanilla_count = static_cast<int>(list_of_texts_.size());
461 const int expanded_count = static_cast<int>(expanded_messages_.size());
462 const int total_rows = vanilla_count + expanded_count;
463
464 // Use ImGuiListClipper for virtualized rendering
465 ImGuiListClipper clipper;
466 clipper.Begin(total_rows);
467
468 while (clipper.Step()) {
469 for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
470 if (row < vanilla_count) {
471 // Vanilla message
472 const auto& message = list_of_texts_[row];
473 TableNextColumn();
474 PushID(message.ID);
475 if (Button(util::HexWord(message.ID).c_str())) {
476 if (current_parse_errors_.empty()) {
478 OpenMessageById(message.ID);
479 }
480 }
481 PopID();
482
483 TableNextColumn();
484 ImGui::TextColored(gui::GetInfoColor(), tr("Vanilla"));
485
486 TableNextColumn();
487 TextWrapped("%s", parsed_messages_[message.ID].c_str());
488
489 TableNextColumn();
490 TextWrapped("%s", util::HexLong(message.Address).c_str());
491 } else {
492 // Expanded message
493 int expanded_idx = row - vanilla_count;
494 const auto& expanded_message = expanded_messages_[expanded_idx];
495 const int display_id =
496 expanded_message_base_id_ + expanded_message.ID;
497 const char* display_text = "Missing text";
498 if (display_id >= 0 &&
499 display_id < static_cast<int>(parsed_messages_.size())) {
500 display_text = parsed_messages_[display_id].c_str();
501 }
502 TableNextColumn();
503 PushID(display_id);
504 if (Button(util::HexWord(display_id).c_str())) {
505 if (current_parse_errors_.empty()) {
507 OpenMessageById(display_id);
508 }
509 }
510 PopID();
511
512 TableNextColumn();
513 ImGui::TextColored(gui::GetWarningColor(), tr("Expanded"));
514
515 TableNextColumn();
516 TextWrapped("%s", display_text);
517
518 TableNextColumn();
519 TextWrapped("%s", util::HexLong(expanded_message.Address).c_str());
520 }
521 }
522 }
523
524 EndTable();
525 }
526 }
527 EndChild();
528}
529
531 Button(absl::StrCat("Message ", current_message_.ID).c_str());
532 if (InputTextMultiline("##MessageEditor", &message_text_box_.text,
533 ImVec2(ImGui::GetContentRegionAvail().x, 0))) {
535 }
536 if (ImGui::IsItemDeactivatedAfterEdit()) {
538 }
539 if (!current_parse_errors_.empty()) {
540 ImGui::TextColored(gui::GetErrorColor(), tr("Message parse errors"));
541 for (const auto& error : current_parse_errors_) {
542 ImGui::BulletText("%s", error.c_str());
543 }
544 }
545 if (!current_parse_warnings_.empty()) {
546 ImGui::TextColored(gui::GetWarningColor(), tr("Message parse warnings"));
547 for (const auto& warning : current_parse_warnings_) {
548 ImGui::BulletText("%s", warning.c_str());
549 }
550 }
552 if (!line_warnings.empty()) {
553 ImGui::TextColored(gui::GetWarningColor(), tr("Line width warnings"));
554 for (const auto& warning : line_warnings) {
555 ImGui::BulletText("%s", warning.c_str());
556 }
557 }
558 Separator();
560
561 ImGui::BeginChild("##MessagePreview", ImVec2(0, 0), true);
563 Text(tr("Message Preview"));
564 if (Button(tr("View Palette"))) {
565 ImGui::OpenPopup("Palette");
566 }
567 if (ImGui::BeginPopup("Palette")) {
569 ImGui::EndPopup();
570 }
572 BeginChild("CurrentGfxFont", ImVec2(348, 0), true,
573 ImGuiWindowFlags_NoScrollWithMouse);
579
580 // Handle mouse wheel scrolling
581 if (ImGui::IsWindowHovered()) {
582 float wheel = ImGui::GetIO().MouseWheel;
583 if (wheel > 0 && message_preview_.shown_lines > 0) {
585 } else if (wheel < 0 &&
588 }
589 }
590
591 // Draw only the visible portion of the text
592 const ImVec2 preview_canvas_size = current_font_gfx16_canvas_.canvas_size();
593 const float dest_width = std::max(0.0f, preview_canvas_size.x - 8.0f);
594 const float dest_height = std::max(0.0f, preview_canvas_size.y - 8.0f);
595 float src_height = 0.0f;
596 if (dest_width > 0.0f && dest_height > 0.0f) {
597 const float src_width =
598 std::min(dest_width * 0.5f, static_cast<float>(kCurrentMessageWidth));
599 src_height =
600 std::min(dest_height * 0.5f, static_cast<float>(kCurrentMessageHeight));
602 current_font_gfx16_bitmap_, ImVec2(0, 0), // Destination position
603 ImVec2(dest_width, dest_height), // Destination size
604 ImVec2(0, message_preview_.shown_lines * 16), // Source position
605 ImVec2(src_width, src_height) // Source size
606 );
607 }
608
609 // Draw scroll break separator lines on the preview canvas
610 {
611 ImDrawList* overlay_draw_list = ImGui::GetWindowDrawList();
612 ImVec2 canvas_p0 = current_font_gfx16_canvas_.zero_point();
613 ImVec2 canvas_sz = current_font_gfx16_canvas_.canvas_size();
614 float line_height = 16.0f;
615 // The bitmap is drawn scaled: dest occupies full canvas, so compute the
616 // vertical scale factor from destination height to source height.
617 float scale_y = 1.0f;
618 if (dest_height > 0.0f && src_height > 0.0f) {
619 scale_y = dest_height / src_height;
620 }
621 for (int marker_line : message_preview_.scroll_marker_lines) {
622 float src_y = (marker_line - message_preview_.shown_lines) * line_height;
623 float y = canvas_p0.y + src_y * scale_y;
624 if (y >= canvas_p0.y && y <= canvas_p0.y + canvas_sz.y) {
625 overlay_draw_list->AddLine(ImVec2(canvas_p0.x, y),
626 ImVec2(canvas_p0.x + canvas_sz.x, y),
627 IM_COL32(100, 180, 255, 180), 1.5f);
628 overlay_draw_list->AddText(ImVec2(canvas_p0.x + canvas_sz.x + 4, y - 6),
629 IM_COL32(100, 180, 255, 200), "[V]");
630 }
631 }
632 }
633
636 EndChild();
637
638 // Message Structure info panel
639 if (ImGui::CollapsingHeader(tr("Message Structure"),
640 ImGuiTreeNodeFlags_DefaultOpen)) {
641 ImGui::Text(tr("Lines: %d"), message_preview_.text_line + 1);
642
643 int scroll_count = 0;
644 int current_line_chars = 0;
645 int line_num = 0;
646
647 for (size_t i = 0; i < current_message_.Data.size(); i++) {
648 uint8_t byte = current_message_.Data[i];
649 if (byte == kScrollVertical) {
650 scroll_count++;
651 ImGui::TextColored(gui::GetInfoColor(),
652 tr(" [V] Scroll at byte %zu (line %d, %d chars)"),
653 i, line_num, current_line_chars);
654 current_line_chars = 0;
655 line_num++;
656 } else if (byte == kLine1) {
657 ImGui::TextColored(ImVec4(0.7f, 0.85f, 0.5f, 1.0f),
658 tr(" [1] Line 1 at byte %zu"), i);
659 current_line_chars = 0;
660 line_num = 0;
661 } else if (byte == kLine2) {
662 ImGui::TextColored(ImVec4(0.7f, 0.85f, 0.5f, 1.0f),
663 tr(" [2] Line 2 at byte %zu"), i);
664 current_line_chars = 0;
665 line_num = 1;
666 } else if (byte == kLine3) {
667 ImGui::TextColored(ImVec4(0.7f, 0.85f, 0.5f, 1.0f),
668 tr(" [3] Line 3 at byte %zu"), i);
669 current_line_chars = 0;
670 line_num = 2;
671 } else if (byte < 100) {
672 current_line_chars++;
673 }
674 }
675
676 if (scroll_count == 0) {
677 ImGui::TextDisabled(tr("No scroll breaks in this message"));
678 } else {
679 ImGui::Text(tr("Total scroll breaks: %d"), scroll_count);
680 }
681
682 // Character width budget
683 ImGui::Separator();
684 ImGui::TextDisabled(tr("Line width budget (max ~170px):"));
685 int estimated_line_width = current_line_chars * 8;
686 float width_ratio = static_cast<float>(estimated_line_width) / 170.0f;
687 ImVec4 width_color = (width_ratio > 1.0f) ? gui::GetErrorColor()
688 : (width_ratio > 0.85f) ? gui::GetWarningColor()
690 ImGui::TextColored(width_color, tr("Last line: ~%dpx / 170px (%d chars)"),
691 estimated_line_width, current_line_chars);
692 }
693
694 ImGui::EndChild();
695}
696
705
707 ImGui::BeginChild("##ExpandedMessageSettings", ImVec2(0, 130), true,
708 ImGuiWindowFlags_AlwaysVerticalScrollbar);
709 ImGui::Text(tr("Expanded Messages"));
710
711 if (ImGui::Button(tr("Load from ROM"))) {
712 auto status = LoadExpandedMessagesFromRom();
713 if (!status.ok()) {
714 LOG_WARN("MessageEditor", "Load from ROM: %s",
715 std::string(status.message()).c_str());
716 }
717 }
718 ImGui::SameLine();
719 if (ImGui::Button(tr("Load from File"))) {
721 if (!path.empty()) {
725 expanded_messages_.clear();
726 std::vector<std::string> parsed_expanded;
728 parsed_expanded, expanded_messages_,
730 if (!status.ok()) {
731 if (auto* popup_manager = dependencies_.popup_manager) {
732 popup_manager->Show("Error");
733 }
734 } else {
735 parsed_messages_.insert(parsed_messages_.end(), parsed_expanded.begin(),
736 parsed_expanded.end());
738 }
739 }
740 }
741
742 if (expanded_messages_.size() > 0) {
743 ImGui::Text(tr("Source: %s"), expanded_message_path_.c_str());
744 ImGui::Text(tr("Messages: %lu"), expanded_messages_.size());
745
746 // Capacity indicator
747 int capacity = GetExpandedTextDataEnd() - GetExpandedTextDataStart() + 1;
748 int used = CalculateExpandedBankUsage();
749 int remaining = capacity - used;
750 float usage_ratio = static_cast<float>(used) / static_cast<float>(capacity);
751
752 ImVec4 capacity_color;
753 if (usage_ratio < 0.75f) {
754 capacity_color = gui::GetSuccessColor();
755 } else if (usage_ratio < 0.90f) {
756 capacity_color = gui::GetWarningColor();
757 } else {
758 capacity_color = gui::GetErrorColor();
759 }
760 ImGui::TextColored(capacity_color, tr("Bank: %d / %d bytes (%d free)"),
761 used, capacity, remaining);
762
763 if (ImGui::Button(tr("Add New Message"))) {
764 MessageData new_message;
765 new_message.ID = expanded_messages_.back().ID + 1;
766 new_message.Address = expanded_messages_.back().Address +
767 expanded_messages_.back().Data.size();
768 expanded_messages_.push_back(new_message);
770 const int display_id = expanded_message_base_id_ + new_message.ID;
771 if (display_id >= 0 &&
772 static_cast<size_t>(display_id) >= parsed_messages_.size()) {
773 parsed_messages_.resize(display_id + 1);
774 }
775 }
776
777 ImGui::SameLine();
778 if (ImGui::Button(tr("Export to JSON"))) {
780 if (!path.empty()) {
782 }
783 }
784 }
785
786 EndChild();
787}
788
790 ImGui::BeginChild("##TextCommands",
791 ImVec2(0, ImGui::GetContentRegionAvail().y / 2), true,
792 ImGuiWindowFlags_AlwaysVerticalScrollbar);
793 static uint8_t command_parameter = 0;
794 gui::InputHexByte("Command Parameter", &command_parameter);
795 for (const auto& text_element : TextCommands) {
796 if (Button(text_element.GenericToken.c_str())) {
797 message_text_box_.text.append(
798 text_element.GetParamToken(command_parameter));
800 }
801 SameLine();
802 TextWrapped("%s", text_element.Description.c_str());
803 Separator();
804 }
805 EndChild();
806}
807
809 ImGui::BeginChild("##SpecialChars",
810 ImVec2(0, ImGui::GetContentRegionAvail().y / 2), true,
811 ImGuiWindowFlags_AlwaysVerticalScrollbar);
812 for (const auto& text_element : SpecialChars) {
813 if (Button(text_element.GenericToken.c_str())) {
814 message_text_box_.text.append(text_element.GenericToken);
816 }
817 SameLine();
818 TextWrapped("%s", text_element.Description.c_str());
819 Separator();
820 }
821 EndChild();
822}
823
825 if (ImGui::BeginChild("##DictionaryChild",
826 ImVec2(0, ImGui::GetContentRegionAvail().y), true,
827 ImGuiWindowFlags_AlwaysVerticalScrollbar)) {
828 if (BeginTable("##Dictionary", 2, kMessageTableFlags)) {
829 TableSetupColumn("ID");
830 TableSetupColumn("Contents");
831 TableHeadersRow();
832
833 // Use ImGuiListClipper for virtualized rendering
834 const int dict_count =
835 static_cast<int>(message_preview_.all_dictionaries_.size());
836 ImGuiListClipper clipper;
837 clipper.Begin(dict_count);
838
839 while (clipper.Step()) {
840 for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
841 const auto& dictionary = message_preview_.all_dictionaries_[row];
842 TableNextColumn();
843 Text("%s", util::HexWord(dictionary.ID).c_str());
844 TableNextColumn();
845 Text("%s", dictionary.Contents.c_str());
846 }
847 }
848
849 EndTable();
850 }
851 }
852 EndChild();
853}
854
855void MessageEditor::UpdateCurrentMessageFromText(const std::string& text) {
857
860
861 auto parse_result = ParseMessageToDataWithDiagnostics(text);
862 current_parse_errors_ = parse_result.errors;
863 current_parse_warnings_ = parse_result.warnings;
864 if (rom_) {
865 // Invalid intermediate input is still unsaved work. Save() will fail
866 // closed until the diagnostics are resolved rather than silently dropping
867 // unsupported characters or unknown tokens.
868 rom_->set_dirty(true);
869 }
870 if (!parse_result.ok()) {
871 return;
872 }
873
874 std::string raw_text = text;
875 raw_text.erase(std::remove(raw_text.begin(), raw_text.end(), '\n'),
876 raw_text.end());
877
878 current_message_.RawString = raw_text;
880 current_message_.Data = std::move(parse_result.bytes);
881
882 int parsed_index = current_message_index_;
885 }
886
887 if (parsed_index >= 0) {
888 if (static_cast<size_t>(parsed_index) >= parsed_messages_.size()) {
889 parsed_messages_.resize(parsed_index + 1);
890 }
891 parsed_messages_[parsed_index] = text;
892 }
893
895 if (current_message_index_ >= 0 &&
896 current_message_index_ < static_cast<int>(expanded_messages_.size())) {
898 }
899 } else {
900 if (current_message_index_ >= 0 &&
901 current_message_index_ < static_cast<int>(list_of_texts_.size())) {
903 }
904 }
905
907}
908
909void MessageEditor::ImportMessageBundleFromFile(const std::string& path) {
912
913 auto entries_or = LoadMessageBundleFromJson(path);
914 if (!entries_or.ok()) {
916 absl::StrFormat("Import failed: %s", entries_or.status().message());
918 return;
919 }
920
921 int applied = 0;
922 int errors = 0;
923 int warnings = 0;
924 int duplicate_errors = 0;
925 int parse_error_entries = 0;
926 int vanilla_updated = 0;
927 int expanded_updated = 0;
928 int expanded_created = 0;
929 bool expanded_modified = false;
930 std::vector<std::string> issue_samples;
931
932 auto add_issue_sample = [&issue_samples](const std::string& issue) {
933 constexpr size_t kMaxIssueSamples = 4;
934 if (issue_samples.size() < kMaxIssueSamples) {
935 issue_samples.push_back(issue);
936 }
937 };
938
939 auto make_entry_key = [](const MessageBundleEntry& entry) {
940 return absl::StrFormat("%s:%d", MessageBankToString(entry.bank), entry.id);
941 };
942
943 std::unordered_map<std::string, int> seen_entries;
944
945 auto entries = entries_or.value();
946 for (const auto& entry : entries) {
947 const std::string entry_key = make_entry_key(entry);
948 if (seen_entries.find(entry_key) != seen_entries.end()) {
949 errors++;
950 duplicate_errors++;
951 add_issue_sample(absl::StrFormat("Duplicate entry for %s", entry_key));
952 continue;
953 }
954 seen_entries.emplace(entry_key, 1);
955
956 auto parse_result = ParseMessageToDataWithDiagnostics(entry.text);
957 auto line_warnings = ValidateMessageLineWidths(entry.text);
958 warnings += static_cast<int>(parse_result.warnings.size());
959 warnings += static_cast<int>(line_warnings.size());
960
961 if (!parse_result.ok()) {
962 errors++;
963 parse_error_entries++;
964 if (!parse_result.errors.empty()) {
965 add_issue_sample(absl::StrFormat("Parse error for %s: %s", entry_key,
966 parse_result.errors.front()));
967 } else {
968 add_issue_sample(absl::StrFormat("Parse error for %s", entry_key));
969 }
970 continue;
971 }
972
973 if (entry.bank == MessageBank::kVanilla) {
974 if (entry.id < 0 || entry.id >= static_cast<int>(list_of_texts_.size())) {
975 errors++;
976 add_issue_sample(
977 absl::StrFormat("Vanilla ID out of range: %d", entry.id));
978 continue;
979 }
980 auto& message = list_of_texts_[entry.id];
981 message.RawString = entry.text;
982 message.ContentsParsed = entry.text;
983 message.Data = parse_result.bytes;
984 message.DataParsed = parse_result.bytes;
985 if (entry.id >= 0 &&
986 entry.id < static_cast<int>(parsed_messages_.size())) {
987 parsed_messages_[entry.id] = entry.text;
988 }
989 vanilla_updated++;
990 applied++;
991 } else {
992 if (entry.id < 0) {
993 errors++;
994 add_issue_sample(
995 absl::StrFormat("Expanded ID out of range: %d", entry.id));
996 continue;
997 }
998 if (entry.id >= static_cast<int>(expanded_messages_.size())) {
999 const int old_size = static_cast<int>(expanded_messages_.size());
1000 const int target_size = entry.id + 1;
1001 expanded_messages_.resize(target_size);
1002 for (int i = old_size; i < target_size; ++i) {
1003 expanded_messages_[i].ID = i;
1004 }
1005 expanded_created += target_size - old_size;
1006 }
1007 auto& message = expanded_messages_[entry.id];
1008 message.RawString = entry.text;
1009 message.ContentsParsed = entry.text;
1010 message.Data = parse_result.bytes;
1011 message.DataParsed = parse_result.bytes;
1012 const int parsed_index = expanded_message_base_id_ + entry.id;
1013 if (parsed_index >= 0) {
1014 if (static_cast<size_t>(parsed_index) >= parsed_messages_.size()) {
1015 parsed_messages_.resize(parsed_index + 1);
1016 }
1017 parsed_messages_[parsed_index] = entry.text;
1018 }
1019 expanded_modified = true;
1020 expanded_updated++;
1021 applied++;
1022 }
1023 }
1024
1025 if (expanded_modified) {
1026 int pos = GetExpandedTextDataStart();
1027 for (auto& message : expanded_messages_) {
1028 message.Address = pos;
1029 pos += static_cast<int>(message.Data.size()) + 1;
1030 }
1031 }
1032
1033 int current_display_id = current_message_index_;
1036 }
1037 if (current_display_id >= 0) {
1038 OpenMessageById(current_display_id);
1039 }
1040
1041 if (errors > 0) {
1042 message_bundle_status_ = absl::StrFormat(
1043 "Import finished with %d errors (%d applied: vanilla %d updated, "
1044 "expanded %d updated/%d created; %d warnings, %d duplicates, %d "
1045 "parse failures).",
1046 errors, applied, vanilla_updated, expanded_updated, expanded_created,
1047 warnings, duplicate_errors, parse_error_entries);
1048 if (!issue_samples.empty()) {
1049 message_bundle_status_ = absl::StrFormat(
1050 "%s Example: %s", message_bundle_status_, issue_samples.front());
1051 }
1053 } else {
1054 message_bundle_status_ = absl::StrFormat(
1055 "Imported %d messages (vanilla %d updated, expanded %d updated/%d "
1056 "created, %d warnings).",
1057 applied, vanilla_updated, expanded_updated, expanded_created, warnings);
1058 }
1059 if (applied > 0 && rom_) {
1060 rom_->set_dirty(true);
1061 }
1062 if (vanilla_updated > 0) {
1064 }
1065 if (expanded_modified) {
1067 }
1068}
1069
1071 // Render the message to the preview bitmap
1073
1074 // Validate preview data before updating
1076 LOG_WARN("MessageEditor", "Preview data is empty, skipping bitmap update");
1077 return;
1078 }
1079
1081 // CRITICAL: Use set_data() to properly update both data_ AND surface_
1082 // mutable_data() returns a reference but doesn't update the surface!
1084
1088 }
1089
1090 // Validate surface was updated
1092 LOG_ERROR("MessageEditor", "Bitmap surface is null after set_data()");
1093 return;
1094 }
1095
1096 // Queue texture update (or create if missing) so changes are visible
1097 const auto command = current_font_gfx16_bitmap_.texture()
1101
1102 LOG_DEBUG(
1103 "MessageEditor",
1104 "Updated message preview bitmap (size: %zu) and queued texture update",
1106 } else {
1107 // Create bitmap and queue texture creation with 8-bit indexed depth
1114
1115 LOG_INFO("MessageEditor",
1116 "Created message preview bitmap (%dx%d) with 8-bit depth and "
1117 "queued texture creation",
1119 }
1120}
1121
1122absl::Status MessageEditor::Save() {
1123 if (!rom_ || !rom_->is_loaded()) {
1124 return absl::FailedPreconditionError("ROM not loaded");
1125 }
1126 if (!current_parse_errors_.empty()) {
1127 return absl::FailedPreconditionError(absl::StrFormat(
1128 "Current message has parse errors: %s", current_parse_errors_.front()));
1129 }
1130
1131 return SaveDirtyDomains(/*include_font_widths=*/true,
1132 /*include_vanilla_messages=*/true,
1133 /*include_expanded_messages=*/true);
1134}
1135
1136absl::StatusOr<MessageEditor::SavePlan> MessageEditor::BuildSavePlan(
1137 bool include_font_widths, bool include_vanilla_messages,
1138 bool include_expanded_messages) const {
1139 if (!rom_ || !rom_->is_loaded()) {
1140 return absl::FailedPreconditionError("ROM not loaded");
1141 }
1142
1143 SavePlan plan;
1144
1145 if (include_font_widths && dirty_state_.font_widths) {
1146 plan.saves_font_widths = true;
1147 plan.writes.push_back(
1149 std::vector<uint8_t>(message_preview_.width_array.begin(),
1151 }
1152
1153 if (include_vanilla_messages && dirty_state_.vanilla_messages) {
1154 std::vector<uint8_t> primary;
1155 std::vector<uint8_t> secondary;
1156 constexpr size_t kPrimaryCapacity = kTextDataEnd - kTextData + 1;
1157 constexpr size_t kSecondaryCapacity = kTextData2End - kTextData2 + 1;
1158 primary.reserve(kPrimaryCapacity);
1159 secondary.reserve(kSecondaryCapacity);
1160
1161 bool in_second_bank = false;
1162 auto append_byte = [&](uint8_t value, bool is_bank_switch) -> absl::Status {
1163 auto& destination = in_second_bank ? secondary : primary;
1164 const size_t capacity =
1165 in_second_bank ? kSecondaryCapacity : kPrimaryCapacity;
1166 if (destination.size() >= capacity) {
1167 const int overflow_pos = (in_second_bank ? kTextData2 : kTextData) +
1168 static_cast<int>(destination.size());
1169 return absl::ResourceExhaustedError(
1170 DisplayTextOverflowError(overflow_pos, !in_second_bank));
1171 }
1172 destination.push_back(value);
1173 if (!in_second_bank && is_bank_switch) {
1174 in_second_bank = true;
1175 }
1176 return absl::OkStatus();
1177 };
1178
1179 for (const auto& message : list_of_texts_) {
1180 bool next_byte_is_command_argument = false;
1181 for (uint8_t value : message.Data) {
1182 const bool is_command_argument = next_byte_is_command_argument;
1183 next_byte_is_command_argument = false;
1184 RETURN_IF_ERROR(append_byte(
1185 value, !is_command_argument && value == kBankSwitchCommand));
1186 if (!is_command_argument) {
1187 const auto command = FindMatchingCommand(value);
1188 next_byte_is_command_argument =
1189 command.has_value() && command->HasArgument;
1190 }
1191 }
1193 append_byte(kMessageTerminator, /*is_bank_switch=*/false));
1194 }
1195 RETURN_IF_ERROR(append_byte(0xFF, /*is_bank_switch=*/false));
1196
1197 plan.saves_vanilla_messages = true;
1198 if (!primary.empty()) {
1199 plan.writes.push_back(
1200 {SaveDomain::kVanillaMessages, kTextData, std::move(primary)});
1201 }
1202 if (!secondary.empty()) {
1203 plan.writes.push_back(
1204 {SaveDomain::kVanillaMessages, kTextData2, std::move(secondary)});
1205 }
1206 }
1207
1208 if (include_expanded_messages && dirty_state_.expanded_messages &&
1209 !expanded_messages_.empty()) {
1210 const int start = GetExpandedTextDataStart();
1211 const int end = GetExpandedTextDataEnd();
1212 if (start < 0 || end < start) {
1213 return absl::InvalidArgumentError("Invalid expanded message region");
1214 }
1215 if (static_cast<uint64_t>(start) >= rom_->size() ||
1216 static_cast<uint64_t>(end) >= rom_->size()) {
1217 return absl::OutOfRangeError(
1218 "Expanded message region is outside the ROM");
1219 }
1220
1221 const int64_t capacity = static_cast<int64_t>(end) - start + 1;
1222 std::vector<uint8_t> bytes;
1223 bytes.reserve(static_cast<size_t>(capacity));
1225
1226 for (size_t index = 0; index < expanded_messages_.size(); ++index) {
1227 const auto& message = expanded_messages_[index];
1228 auto parsed = ParseMessageToDataWithDiagnostics(message.RawString);
1229 if (!parsed.ok()) {
1230 return absl::InvalidArgumentError(
1231 absl::StrFormat("Expanded message %d is invalid: %s",
1232 static_cast<int>(index), parsed.errors.front()));
1233 }
1234 if (message.RawString.find("[BANK]") != std::string::npos) {
1235 return absl::InvalidArgumentError(absl::StrFormat(
1236 "Expanded message %d contains [BANK], which is only valid in the "
1237 "vanilla message stream",
1238 static_cast<int>(index)));
1239 }
1240
1241 const int64_t needed = static_cast<int64_t>(parsed.bytes.size()) + 1;
1242 if (static_cast<int64_t>(bytes.size()) + needed + 1 > capacity) {
1243 return absl::ResourceExhaustedError(absl::StrFormat(
1244 "Expanded message data exceeds bank boundary "
1245 "(at message %d, used=%d, needed=%d, capacity=%d, end=0x%06X)",
1246 static_cast<int>(index), static_cast<int>(bytes.size()),
1247 static_cast<int>(needed), static_cast<int>(capacity), end));
1248 }
1249
1250 plan.expanded_message_addresses.push_back(start +
1251 static_cast<int>(bytes.size()));
1252 bytes.insert(bytes.end(), parsed.bytes.begin(), parsed.bytes.end());
1253 bytes.push_back(kMessageTerminator);
1254 }
1255 bytes.push_back(0xFF);
1256
1257 plan.saves_expanded_messages = true;
1258 plan.writes.push_back({SaveDomain::kExpandedMessages,
1259 static_cast<uint32_t>(start), std::move(bytes)});
1260 }
1261
1262 for (const auto& write : plan.writes) {
1263 const uint64_t end =
1264 static_cast<uint64_t>(write.start) + write.bytes.size();
1265 if (end > rom_->size() || end > std::numeric_limits<uint32_t>::max()) {
1266 return absl::OutOfRangeError(absl::StrFormat(
1267 "Message save range [0x%06X, 0x%06llX) is outside the ROM",
1268 write.start, static_cast<unsigned long long>(end)));
1269 }
1270 }
1271
1272 return plan;
1273}
1274
1275absl::Status MessageEditor::ValidateSavePlan(const SavePlan& plan) const {
1276 if (!dependencies_.project ||
1277 !dependencies_.project->hack_manifest.loaded() || plan.writes.empty()) {
1278 return absl::OkStatus();
1279 }
1280
1281 std::vector<std::pair<uint32_t, uint32_t>> ranges;
1282 ranges.reserve(plan.writes.size());
1283 for (const auto& write : plan.writes) {
1284 ranges.emplace_back(write.start, write.end());
1285 }
1286
1287 const auto& yaze_project = *dependencies_.project;
1288 if (yaze_project.rom_metadata.write_policy ==
1291 absl::StrContains(yaze_project.hack_manifest.hack_name(),
1292 "Oracle of Secrets")) {
1293 return absl::PermissionDeniedError(
1294 "Oracle of Secrets expanded messages are owned by ASM. No message "
1295 "bytes were written; keep the draft in Yaze, edit Core/message.asm, "
1296 "rebuild Oracle of Secrets, and reopen the rebuilt ROM before "
1297 "retrying.");
1298 }
1299
1300 const auto status = ValidateHackManifestSaveConflicts(
1301 yaze_project.hack_manifest, yaze_project.rom_metadata.write_policy,
1302 ranges, "message data", "MessageEditor", dependencies_.toast_manager);
1303 return status;
1304}
1305
1306absl::Status MessageEditor::ApplySavePlan(const SavePlan& plan) {
1307 if (plan.writes.empty()) {
1308 return absl::OkStatus();
1309 }
1310
1312 for (const auto& write : plan.writes) {
1313 const char* label = "MessageData";
1314 switch (write.domain) {
1316 label = "MessageFontWidths";
1317 break;
1319 label = "VanillaMessageBank";
1320 break;
1322 label = "ExpandedMessageBank";
1323 break;
1324 }
1325 RETURN_IF_ERROR(fence.Allow(write.start, write.end(), label));
1326 }
1327
1328 ScopedRomTransaction transaction(*rom_);
1329 yaze::rom::ScopedWriteFence fence_scope(rom_, &fence);
1330 for (const auto& write : plan.writes) {
1332 rom_->WriteVector(static_cast<int>(write.start), write.bytes));
1333 }
1334 transaction.Commit();
1335
1336 if (plan.saves_expanded_messages) {
1337 for (size_t index = 0; index < expanded_messages_.size() &&
1338 index < plan.expanded_message_addresses.size();
1339 ++index) {
1340 expanded_messages_[index].Address =
1341 plan.expanded_message_addresses[index];
1342 }
1344 }
1345
1346 return absl::OkStatus();
1347}
1348
1349absl::Status MessageEditor::SaveDirtyDomains(bool include_font_widths,
1350 bool include_vanilla_messages,
1351 bool include_expanded_messages) {
1352 ASSIGN_OR_RETURN(const SavePlan plan,
1353 BuildSavePlan(include_font_widths, include_vanilla_messages,
1354 include_expanded_messages));
1357 RecordSavedDomains(plan);
1358 return absl::OkStatus();
1359}
1360
1362 return SaveDirtyDomains(/*include_font_widths=*/false,
1363 /*include_vanilla_messages=*/false,
1364 /*include_expanded_messages=*/true);
1365}
1366
1369 return absl::FailedPreconditionError(
1370 "Message save transaction is already active");
1371 }
1376 for (const auto& message : expanded_messages_) {
1377 transaction_expanded_address_snapshot_.push_back(message.Address);
1378 }
1380 return absl::OkStatus();
1381}
1382
1385 return;
1386 }
1388 for (size_t index = 0; index < expanded_messages_.size() &&
1390 ++index) {
1391 expanded_messages_[index].Address =
1393 }
1399}
1400
1411
1413 bool* dirty = nullptr;
1414 bool* transaction_saved = nullptr;
1415 switch (domain) {
1417 dirty = &dirty_state_.font_widths;
1418 transaction_saved = &transaction_saved_domains_.font_widths;
1419 break;
1422 transaction_saved = &transaction_saved_domains_.vanilla_messages;
1423 break;
1426 transaction_saved = &transaction_saved_domains_.expanded_messages;
1427 break;
1428 }
1429 *dirty = true;
1431 *transaction_saved = false;
1432 }
1433 if (rom_) {
1434 rom_->set_dirty(true);
1435 }
1436}
1437
1442 ClearSavedDomains(saved);
1443 return;
1444 }
1445 transaction_saved_domains_.font_widths |= saved.font_widths;
1446 transaction_saved_domains_.vanilla_messages |= saved.vanilla_messages;
1447 transaction_saved_domains_.expanded_messages |= saved.expanded_messages;
1448}
1449
1451 if (saved.font_widths) {
1452 dirty_state_.font_widths = false;
1453 }
1454 if (saved.vanilla_messages) {
1456 }
1457 if (saved.expanded_messages) {
1459 }
1460}
1461
1469
1471 if (!rom_ || !rom_->is_loaded()) {
1472 return absl::FailedPreconditionError("ROM not loaded");
1473 }
1474
1477
1478 expanded_messages_.clear();
1481 std::min(GetExpandedTextDataEnd(), static_cast<int>(rom_->size()) - 1));
1482
1483 if (expanded_messages_.empty()) {
1484 return absl::NotFoundError(
1485 "No expanded messages found in ROM at expanded text region");
1486 }
1487
1488 // Parse the expanded messages and append to the unified list
1489 auto parsed_expanded =
1491 for (const auto& msg : expanded_messages_) {
1492 if (msg.ID >= 0 && msg.ID < static_cast<int>(parsed_expanded.size())) {
1493 parsed_messages_.push_back(parsed_expanded[msg.ID]);
1494 }
1495 }
1496
1497 expanded_message_path_ = "(ROM)";
1499 return absl::OkStatus();
1500}
1501
1503 if (expanded_messages_.empty())
1504 return 0;
1505 int total = 0;
1506 for (const auto& msg : expanded_messages_) {
1507 total += static_cast<int>(msg.Data.size()) + 1; // +1 for 0x7F
1508 }
1509 total += 1; // +1 for final 0xFF
1510 return total;
1511}
1512
1513absl::Status MessageEditor::Cut() {
1514 // Ensure that text is currently selected in the text box.
1515 if (!message_text_box_.text.empty()) {
1516 // Cut the selected text in the control and paste it into the Clipboard.
1518 }
1519 return absl::OkStatus();
1520}
1521
1522absl::Status MessageEditor::Paste() {
1523 // Determine if there is any text in the Clipboard to paste into the
1524 if (ImGui::GetClipboardText() != nullptr) {
1525 // Paste the text from the Clipboard into the text box.
1527 }
1528 return absl::OkStatus();
1529}
1530
1531absl::Status MessageEditor::Copy() {
1532 // Ensure that text is selected in the text box.
1534 // Copy the selected text to the Clipboard.
1536 }
1537 return absl::OkStatus();
1538}
1539
1541 if (pending_undo_before_.has_value()) {
1542 // If we're still editing the same message, keep the existing "before"
1543 // snapshot so the entire edit session becomes a single undo step.
1544 if (pending_undo_before_->message_index == current_message_index_ &&
1546 return;
1547 }
1549 }
1550
1551 // Capture current state as "before"
1552 int parsed_index = current_message_index_;
1555 }
1556 std::string text;
1557 if (parsed_index >= 0 &&
1558 parsed_index < static_cast<int>(parsed_messages_.size())) {
1559 text = parsed_messages_[parsed_index];
1560 }
1564}
1565
1567 if (!pending_undo_before_.has_value())
1568 return;
1569
1570 // The "after" snapshot must correspond to the same message as the pending
1571 // "before", even if the user navigated to a different message in the UI.
1572 const int message_index = pending_undo_before_->message_index;
1573 const bool is_expanded = pending_undo_before_->is_expanded;
1574
1575 MessageData after_message;
1576 if (is_expanded) {
1577 if (message_index < 0 ||
1578 message_index >= static_cast<int>(expanded_messages_.size())) {
1579 pending_undo_before_.reset();
1580 return;
1581 }
1582 after_message = expanded_messages_[message_index];
1583 } else {
1584 if (message_index < 0 ||
1585 message_index >= static_cast<int>(list_of_texts_.size())) {
1586 pending_undo_before_.reset();
1587 return;
1588 }
1589 after_message = list_of_texts_[message_index];
1590 }
1591
1592 int parsed_index = message_index;
1593 if (is_expanded) {
1594 parsed_index = expanded_message_base_id_ + message_index;
1595 }
1596 std::string text;
1597 if (parsed_index >= 0 &&
1598 parsed_index < static_cast<int>(parsed_messages_.size())) {
1599 text = parsed_messages_[parsed_index];
1600 }
1601 MessageSnapshot after{std::move(after_message), std::move(text),
1602 message_index, is_expanded};
1603
1604 undo_manager_.Push(std::make_unique<MessageEditAction>(
1605 std::move(*pending_undo_before_), std::move(after),
1606 [this](const MessageSnapshot& s) { ApplySnapshot(s); }));
1607 pending_undo_before_.reset();
1608}
1609
1611 current_message_ = snapshot.message;
1615 const auto diagnostics =
1617 current_parse_errors_ = diagnostics.errors;
1618 current_parse_warnings_ = diagnostics.warnings;
1619
1620 int parsed_index = snapshot.message_index;
1621 if (snapshot.is_expanded) {
1622 parsed_index = expanded_message_base_id_ + snapshot.message_index;
1623 }
1624 if (parsed_index >= 0 &&
1625 parsed_index < static_cast<int>(parsed_messages_.size())) {
1626 parsed_messages_[parsed_index] = snapshot.parsed_text;
1627 }
1628
1629 if (snapshot.is_expanded) {
1630 if (snapshot.message_index >= 0 &&
1631 snapshot.message_index < static_cast<int>(expanded_messages_.size())) {
1632 expanded_messages_[snapshot.message_index] = snapshot.message;
1633 }
1634 } else {
1635 if (snapshot.message_index >= 0 &&
1636 snapshot.message_index < static_cast<int>(list_of_texts_.size())) {
1637 list_of_texts_[snapshot.message_index] = snapshot.message;
1638 }
1639 }
1640
1641 if (rom_) {
1642 rom_->set_dirty(true);
1643 }
1647}
1648
1649absl::Status MessageEditor::Undo() {
1651 return undo_manager_.Undo();
1652}
1653
1654absl::Status MessageEditor::Redo() {
1655 return undo_manager_.Redo();
1656}
1657
1659 // Determine if any text is selected in the TextBox control.
1661 // clear all of the text in the textbox.
1663 }
1664}
1665
1667 // Determine if any text is selected in the TextBox control.
1669 // Select all text in the text box.
1671
1672 // Move the cursor to the text box.
1674 }
1675}
1676
1677absl::Status MessageEditor::Find() {
1678 if (ImGui::Begin("Find & Replace", nullptr,
1679 ImGuiWindowFlags_AlwaysAutoResize)) {
1680 static char find_text[256] = "";
1681 static char replace_text[256] = "";
1682 ImGui::InputText(tr("Search"), find_text, IM_ARRAYSIZE(find_text));
1683 ImGui::InputText(tr("Replace with"), replace_text,
1684 IM_ARRAYSIZE(replace_text));
1685
1686 if (ImGui::Button(tr("Find Next"))) {
1687 search_text_ = find_text;
1688 replace_status_.clear();
1689 }
1690
1691 ImGui::SameLine();
1692 if (ImGui::Button(tr("Find All"))) {
1693 search_text_ = find_text;
1694 replace_status_.clear();
1695 }
1696
1697 ImGui::SameLine();
1698 if (ImGui::Button(tr("Replace"))) {
1699 search_text_ = find_text;
1700 replace_text_ = replace_text;
1701 int count = ReplaceCurrentMatch();
1702 if (count > 0) {
1703 replace_status_ = "Replaced 1 occurrence";
1704 replace_status_error_ = false;
1705 } else {
1706 replace_status_ = "No match found in current message";
1707 replace_status_error_ = true;
1708 }
1709 }
1710
1711 ImGui::SameLine();
1712 if (ImGui::Button(tr("Replace All"))) {
1713 search_text_ = find_text;
1714 replace_text_ = replace_text;
1715 int count = ReplaceAllMatches();
1716 if (count >= 0) {
1717 replace_status_ = absl::StrFormat("Replaced %d occurrence%s", count,
1718 count == 1 ? "" : "s");
1719 replace_status_error_ = (count == 0);
1720 }
1721 }
1722
1723 ImGui::Checkbox(tr("Case Sensitive"), &case_sensitive_);
1724 ImGui::SameLine();
1725 ImGui::Checkbox(tr("Match Whole Word"), &match_whole_word_);
1726
1727 if (!replace_status_.empty()) {
1728 ImVec4 color =
1730 ImGui::TextColored(color, "%s", replace_status_.c_str());
1731 }
1732 }
1733 ImGui::End();
1734
1735 return absl::OkStatus();
1736}
1737
1739 if (search_text_.empty())
1740 return 0;
1741
1742 std::string& text = message_text_box_.text;
1743 std::string search = search_text_;
1744 std::string source = text;
1745
1746 if (!case_sensitive_) {
1747 std::transform(search.begin(), search.end(), search.begin(), ::tolower);
1748 std::transform(source.begin(), source.end(), source.begin(), ::tolower);
1749 }
1750
1751 size_t pos = source.find(search);
1752 if (pos == std::string::npos)
1753 return 0;
1754
1755 // Check whole word boundary if required
1756 if (match_whole_word_) {
1757 bool start_ok = (pos == 0 || !std::isalnum(source[pos - 1]));
1758 bool end_ok = (pos + search.size() >= source.size() ||
1759 !std::isalnum(source[pos + search.size()]));
1760 if (!start_ok || !end_ok) {
1761 // Search for a whole-word match further in the string
1762 while (pos != std::string::npos) {
1763 start_ok = (pos == 0 || !std::isalnum(source[pos - 1]));
1764 end_ok = (pos + search.size() >= source.size() ||
1765 !std::isalnum(source[pos + search.size()]));
1766 if (start_ok && end_ok)
1767 break;
1768 pos = source.find(search, pos + 1);
1769 }
1770 if (pos == std::string::npos)
1771 return 0;
1772 }
1773 }
1774
1775 // Perform the replacement in the original (case-preserving) text
1776 text.replace(pos, search_text_.size(), replace_text_);
1779 return 1;
1780}
1781
1783 if (search_text_.empty()) {
1784 return 0;
1785 }
1786 if (!current_parse_errors_.empty()) {
1788 "Replace All blocked: resolve the current message parse errors first";
1789 replace_status_error_ = true;
1790 return -1;
1791 }
1792
1793 auto replace_in_text = [&](std::string& text) -> int {
1794 int count = 0;
1795 std::string search = search_text_;
1796
1797 if (!case_sensitive_) {
1798 std::transform(search.begin(), search.end(), search.begin(), ::tolower);
1799 }
1800
1801 size_t pos = 0;
1802 while (pos < text.size()) {
1803 std::string source = text;
1804 if (!case_sensitive_) {
1805 std::transform(source.begin(), source.end(), source.begin(), ::tolower);
1806 }
1807
1808 size_t found = source.find(search, pos);
1809 if (found == std::string::npos)
1810 break;
1811
1812 if (match_whole_word_) {
1813 bool start_ok = (found == 0 || !std::isalnum(source[found - 1]));
1814 bool end_ok = (found + search.size() >= source.size() ||
1815 !std::isalnum(source[found + search.size()]));
1816 if (!start_ok || !end_ok) {
1817 pos = found + 1;
1818 continue;
1819 }
1820 }
1821
1822 text.replace(found, search_text_.size(), replace_text_);
1823 pos = found + replace_text_.size();
1824 count++;
1825 }
1826 return count;
1827 };
1828
1829 struct PlannedReplacement {
1830 int message_index;
1831 bool is_expanded;
1832 std::string text;
1833 int count;
1834 };
1835 std::vector<PlannedReplacement> plan;
1836
1837 const auto plan_replacements = [&](const std::vector<MessageData>& messages,
1838 bool is_expanded) -> bool {
1839 for (size_t i = 0; i < messages.size(); ++i) {
1840 const int parsed_index =
1841 (is_expanded ? expanded_message_base_id_ : 0) + static_cast<int>(i);
1842 if (parsed_index < 0 ||
1843 parsed_index >= static_cast<int>(parsed_messages_.size())) {
1844 continue;
1845 }
1846
1847 std::string text = parsed_messages_[parsed_index];
1848 const int count = replace_in_text(text);
1849 if (count == 0) {
1850 continue;
1851 }
1852
1853 const auto parsed = ParseMessageToDataWithDiagnostics(text);
1854 if (!parsed.ok() ||
1855 (is_expanded && text.find("[BANK]") != std::string::npos)) {
1856 const std::string error =
1857 !parsed.ok() ? parsed.errors.front()
1858 : "[BANK] is not valid in expanded messages";
1859 replace_status_ = absl::StrFormat(
1860 "Replace All aborted at %s message %d: %s",
1861 is_expanded ? "expanded" : "vanilla", static_cast<int>(i), error);
1862 replace_status_error_ = true;
1863 return false;
1864 }
1865
1866 plan.push_back(
1867 {static_cast<int>(i), is_expanded, std::move(text), count});
1868 }
1869 return true;
1870 };
1871
1872 // Validate the complete batch before changing any message or undo state.
1873 if (!plan_replacements(list_of_texts_, false) ||
1874 !plan_replacements(expanded_messages_, true)) {
1875 return -1;
1876 }
1877
1878 const int previous_index = current_message_index_;
1879 const bool previous_expanded = current_message_is_expanded_;
1880 int total_replacements = 0;
1881
1882 for (const auto& replacement : plan) {
1883 current_message_ = replacement.is_expanded
1884 ? expanded_messages_[replacement.message_index]
1885 : list_of_texts_[replacement.message_index];
1886 current_message_index_ = replacement.message_index;
1887 current_message_is_expanded_ = replacement.is_expanded;
1888 message_text_box_.text = replacement.text;
1889 UpdateCurrentMessageFromText(replacement.text);
1891 total_replacements += replacement.count;
1892 }
1893
1894 current_message_index_ = previous_index;
1895 current_message_is_expanded_ = previous_expanded;
1896
1897 // Refresh the current message's text box from updated data
1898 int current_parsed_idx = current_message_index_;
1901 }
1902 if (current_parsed_idx >= 0 &&
1903 current_parsed_idx < static_cast<int>(parsed_messages_.size())) {
1904 message_text_box_.text = parsed_messages_[current_parsed_idx];
1905 }
1906
1907 // Refresh current_message_ to reflect replacements
1909 if (current_message_index_ >= 0 &&
1910 current_message_index_ < static_cast<int>(expanded_messages_.size())) {
1912 }
1913 } else {
1914 if (current_message_index_ >= 0 &&
1915 current_message_index_ < static_cast<int>(list_of_texts_.size())) {
1917 }
1918 }
1919
1920 const auto current_diagnostics =
1922 current_parse_errors_ = current_diagnostics.errors;
1923 current_parse_warnings_ = current_diagnostics.warnings;
1924
1925 return total_replacements;
1926}
1927
1928} // namespace editor
1929} // namespace yaze
auto begin()
Definition rom.h:153
void set_dirty(bool dirty)
Definition rom.h:146
auto mutable_data()
Definition rom.h:152
absl::Status WriteVector(int addr, std::vector< uint8_t > data)
Definition rom.cc:658
auto data() const
Definition rom.h:151
auto size() const
Definition rom.h:150
bool is_loaded() const
Definition rom.h:144
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.
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:201
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:853
void SetPalette(const SnesPalette &palette)
Set the palette for the bitmap using SNES palette format.
Definition bitmap.cc:384
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:1162
void DrawContextMenu()
Definition canvas.cc:692
bool DrawTileSelector(int size, int size_y=0)
Definition canvas.cc:1098
auto canvas_size() const
Definition canvas.h:357
auto zero_point() const
Definition canvas.h:348
CanvasConfig & GetConfig()
Definition canvas.h:229
void DrawBackground(ImVec2 canvas_size=ImVec2(0, 0))
Definition canvas.cc:602
void DrawGrid(float grid_step=64.0f, int tile_id_offset=8)
Definition canvas.cc:1484
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
std::string DisplayTextOverflowError(int pos, bool bank)
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 kTextData2
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
constexpr int kTextData2End
std::vector< DictionaryEntry > BuildDictionaryEntries(Rom *rom)
constexpr uint8_t kBankSwitchCommand
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
std::vector< MessageData > ReadExpandedTextData(uint8_t *rom, int pos)
std::optional< TextElement > FindMatchingCommand(uint8_t b)
MessageParseResult ParseMessageToDataWithDiagnostics(std::string_view str)
int GetExpandedTextDataEnd()
std::vector< std::string > ValidateMessageLineWidths(const std::string &message)
constexpr uint8_t kLine3
constexpr int kTextDataEnd
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:686
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:376
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
Room transition destination.
Definition zelda.h:448
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