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 <cstdio>
6#include <cstring>
7#include <filesystem>
8#include <fstream>
9#include <iterator>
10#include <limits>
11#include <string>
12#include <unordered_map>
13#include <vector>
14
15#include "absl/status/status.h"
16#include "absl/strings/match.h"
17#include "absl/strings/str_cat.h"
18#include "absl/strings/str_format.h"
25#include "app/gfx/core/bitmap.h"
32#include "app/gui/core/icons.h"
33#include "app/gui/core/input.h"
35#include "app/gui/core/style.h"
39#include "imgui.h"
40#include "imgui/imgui_internal.h"
41#include "imgui/misc/cpp/imgui_stdlib.h"
42#include "rom/rom.h"
43#include "rom/snes.h"
44#include "rom/transaction.h"
45#include "rom/write_fence.h"
46#include "util/file_util.h"
47#include "util/hex.h"
48#include "util/log.h"
49
50namespace yaze {
51namespace editor {
52
53using ImGui::BeginChild;
54using ImGui::BeginTable;
55using ImGui::Button;
56using ImGui::EndChild;
57using ImGui::EndTable;
58using ImGui::InputTextMultiline;
59using ImGui::PopID;
60using ImGui::PushID;
61using ImGui::SameLine;
62using ImGui::Separator;
63using ImGui::TableHeadersRow;
64using ImGui::TableNextColumn;
65using ImGui::TableSetupColumn;
66using ImGui::Text;
67using ImGui::TextWrapped;
68
69constexpr ImGuiTableFlags kMessageTableFlags = ImGuiTableFlags_Hideable |
70 ImGuiTableFlags_Borders |
71 ImGuiTableFlags_Resizable;
72
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]() { DrawMessageTools(); }));
100
101 // Show message list by default
102 window_manager->OpenWindow(session_id, "message.message_list");
103
104 for (int i = 0; i < kWidthArraySize; i++) {
106 }
107
109 const int message_read_limit = static_cast<int>(
110 std::min(static_cast<size_t>(rom()->size()),
111 static_cast<size_t>(std::numeric_limits<int>::max())));
113 ReadAllTextData(rom()->mutable_data(), kTextData, message_read_limit);
114 LOG_INFO("MessageEditor", "Loaded %zu messages from ROM",
115 list_of_texts_.size());
116
121 }
125
127
128 if (!list_of_texts_.empty()) {
129 // Default to message 1 if available, otherwise 0
130 size_t default_idx = list_of_texts_.size() > 1 ? 1 : 0;
131 current_message_ = list_of_texts_[default_idx];
135 current_parse_errors_.clear();
138 } else {
139 LOG_ERROR("MessageEditor", "No messages found in ROM!");
140 }
141}
142
143bool MessageEditor::OpenMessageById(int display_id) {
144 // Do not discard an invalid in-progress draft by navigating away. The user
145 // can correct it or undo it before selecting another message.
146 if (!current_parse_errors_.empty()) {
147 return false;
148 }
149
150 const int vanilla_count = static_cast<int>(list_of_texts_.size());
151 const int expanded_base_id = expanded_message_base_id_;
152
153 int expanded_count = static_cast<int>(expanded_messages_.size());
154 auto resolved = ResolveMessageDisplayId(display_id, vanilla_count,
155 expanded_base_id, expanded_count);
156
157 // Projects without a canonical source can inspect expanded ROM messages on
158 // navigation. A missing project source must remain an explicit source error.
159 if (!resolved.has_value() && expanded_count == 0 &&
160 !HasMessageSourceLayout() && display_id >= expanded_base_id && rom_ &&
161 rom_->is_loaded()) {
162 const auto range = ExpandedInspectionRange();
163 const int start = range.first;
164 const int end = range.second;
165 const size_t rom_size = rom_->size();
166 if (start >= 0 && end >= start && static_cast<size_t>(end) < rom_size) {
167 const auto status = LoadExpandedMessagesFromRom();
168 if (!status.ok()) {
169 LOG_DEBUG("MessageEditor",
170 "OpenMessageById: expanded load skipped/failed: %s",
171 std::string(status.message()).c_str());
172 }
173 expanded_count = static_cast<int>(expanded_messages_.size());
174 resolved = ResolveMessageDisplayId(display_id, vanilla_count,
175 expanded_base_id, expanded_count);
176 } else {
177 LOG_DEBUG("MessageEditor",
178 "OpenMessageById: expanded region out of bounds (0x%X-0x%X, "
179 "rom=0x%zX)",
180 start, end, rom_size);
181 }
182 }
183
184 if (!resolved.has_value()) {
185 return false;
186 }
187
189 const size_t session_id = dependencies_.session_id;
191 "message.message_list");
193 "message.message_editor");
194 }
195
196 if (!resolved->is_expanded) {
197 const int idx = resolved->index;
198 if (idx < 0 || idx >= vanilla_count) {
199 return false;
200 }
201
202 const auto& message = list_of_texts_[idx];
203 current_message_ = message;
204 current_message_index_ = message.ID;
206
207 const int parsed_idx = resolved->display_id;
208 if (parsed_idx >= 0 &&
209 parsed_idx < static_cast<int>(parsed_messages_.size())) {
211 } else {
212 message_text_box_.text.clear();
213 }
214
215 current_parse_errors_.clear();
217 editor_cursor_ = 0;
218 find_has_match_ = false;
223 return true;
224 }
225
226 // Expanded message.
227 const int idx = resolved->index;
228 if (idx < 0 || idx >= expanded_count) {
229 return false;
230 }
231
232 const auto& message = expanded_messages_[idx];
233 current_message_ = message;
234 current_message_index_ = message.ID;
236
237 const int parsed_idx = resolved->display_id;
238 if (parsed_idx >= 0 &&
239 parsed_idx < static_cast<int>(parsed_messages_.size())) {
241 } else {
242 message_text_box_.text.clear();
243 }
244
245 current_parse_errors_.clear();
247 editor_cursor_ = 0;
248 find_has_match_ = false;
253 return true;
254}
255
257 int base_id = static_cast<int>(list_of_texts_.size());
259 const auto& layout = dependencies_.project->hack_manifest.message_layout();
260 if (layout.first_expanded_id != 0) {
261 base_id = static_cast<int>(layout.first_expanded_id);
262 }
263 }
264
265 // Never allow the expanded base to precede the vanilla message count; this
266 // prevents truncating/overlapping IDs when the manifest is missing/mistyped.
267 base_id = std::max(base_id, static_cast<int>(list_of_texts_.size()));
268 return base_id;
269}
270
271absl::Status MessageEditor::Load() {
272 gfx::ScopedTimer timer("MessageEditor::Load");
274 return absl::OkStatus();
275 }
277 // Loading assets may be requested again during Save or editor activation.
278 // Never replace an existing draft (including an explicit ROM inspection).
279 if (!expanded_messages_.empty() || !source_loaded_canonical_sha_.empty()) {
280 return absl::OkStatus();
281 }
282 const auto status = LoadCanonicalExpandedSource();
283 if (!status.ok()) {
284 message_bundle_status_ = absl::StrFormat(
285 "Expanded source unavailable: %s. Vanilla dialogue remains available.",
286 status.message());
288 }
289 // Source errors belong in the Message editor; they must not prevent opening
290 // the project or editing its independent vanilla messages.
291 return absl::OkStatus();
292}
293
294absl::Status MessageEditor::Update() {
295 // Panels are drawn by WorkspaceWindowManager. Shortcuts stay here so they
296 // run once per frame, and only while a message window actually has focus.
298 return absl::OkStatus();
299}
300
305
306namespace {
307
309 int* cursor = nullptr;
310 int* select_start = nullptr;
311 int* select_end = nullptr;
312};
313
314int MessageInputCallback(ImGuiInputTextCallbackData* data) {
315 auto* state = static_cast<MessageInputState*>(data->UserData);
316 if (state == nullptr) {
317 return 0;
318 }
319 if (state->select_start != nullptr && state->select_end != nullptr &&
320 *state->select_start >= 0) {
321 const int start = *state->select_start;
322 const int end = std::max(start, *state->select_end);
323 data->CursorPos = std::min(end, data->BufTextLen);
324 data->SelectionStart = std::min(start, data->BufTextLen);
325 data->SelectionEnd = data->CursorPos;
326 *state->select_start = -1;
327 *state->select_end = -1;
328 }
329 if (state->cursor != nullptr) {
330 *state->cursor = data->CursorPos;
331 }
332 return 0;
333}
334
336 ImGuiContext* context = ImGui::GetCurrentContext();
337 if (context == nullptr || context->NavWindow == nullptr) {
338 return false;
339 }
340 ImGuiWindow* root = context->NavWindow->RootWindow != nullptr
341 ? context->NavWindow->RootWindow
342 : context->NavWindow;
343 return root->Name != nullptr &&
344 std::strstr(root->Name, "message.message") != nullptr;
345}
346
347} // namespace
348
350 if (!MessageSurfaceFocused()) {
351 return;
352 }
353 const ImGuiIO& io = ImGui::GetIO();
354 if (io.KeyAlt && !io.KeyCtrl && !io.KeyShift &&
355 ImGui::IsKeyPressed(ImGuiKey_UpArrow, false)) {
357 }
358 if (io.KeyAlt && !io.KeyCtrl && !io.KeyShift &&
359 ImGui::IsKeyPressed(ImGuiKey_DownArrow, false)) {
361 }
362 if (!io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_F3, false)) {
363 if (!find_panel_open_) {
364 find_panel_open_ = true;
365 find_focus_search_ = true;
366 }
367 AdvanceFind(!io.KeyShift);
368 }
369}
370
372 if (status_bar == nullptr) {
373 return;
374 }
375 const char* bank = current_message_is_expanded_ ? "Expanded" : "Vanilla";
376 status_bar->SetCustomSegment(
377 "Message",
378 absl::StrFormat("%s $%04X", bank, CurrentDisplayId() & 0xFFFF));
379 status_bar->SetCustomSegment(
380 "Size", absl::StrFormat("%zu B", current_message_.Data.size()));
381 status_bar->SetCustomSegment(
382 "Lines", absl::StrFormat("%d", message_preview_.text_line + 1));
383 const int shown = static_cast<int>(CollectMessageRows(true).size());
384 const int total = static_cast<int>(CollectMessageRows(false).size());
385 if (!list_filter_.empty() || list_bank_filter_ != 0) {
386 status_bar->SetCustomSegment("Shown",
387 absl::StrFormat("%d/%d", shown, total));
388 }
389 if (!current_parse_errors_.empty()) {
390 status_bar->SetEditorMode("Parse error");
393 status_bar->SetEditorMode("Unsaved");
394 } else {
395 status_bar->SetEditorMode("Dialogue");
396 }
397}
398
405
406std::string_view MessageEditor::ContentsForDisplayId(int display_id) const {
407 if (display_id < 0 ||
408 display_id >= static_cast<int>(parsed_messages_.size())) {
409 return {};
410 }
411 return parsed_messages_[display_id];
412}
413
414std::vector<MessageEditor::MessageRow> MessageEditor::CollectMessageRows(
415 bool apply_filter) const {
416 std::vector<MessageRow> rows;
417 rows.reserve(list_of_texts_.size() + expanded_messages_.size());
418 const auto bank = static_cast<MessageBankFilter>(list_bank_filter_);
419 const auto consider = [&](int display_id, bool expanded, uint32_t address) {
420 if (apply_filter &&
421 !MessageListQueryMatches(display_id, address,
422 ContentsForDisplayId(display_id), expanded,
423 bank, list_filter_)) {
424 return;
425 }
426 rows.push_back(MessageRow{display_id, expanded, address});
427 };
428 for (const auto& message : list_of_texts_) {
429 consider(message.ID, false, static_cast<uint32_t>(message.Address));
430 }
431 for (const auto& message : expanded_messages_) {
432 consider(expanded_message_base_id_ + message.ID, true,
433 static_cast<uint32_t>(message.Address));
434 }
435 return rows;
436}
437
438void MessageEditor::InsertTokenAtCursor(const std::string& token) {
439 if (token.empty()) {
440 return;
441 }
442 auto& text = message_text_box_.text;
443 const int pos = std::clamp(editor_cursor_, 0, static_cast<int>(text.size()));
444 text.insert(static_cast<size_t>(pos), token);
445 editor_cursor_ = pos + static_cast<int>(token.size());
448 ImGui::ClearActiveID();
450}
451
453 if (delta == 0) {
454 return;
455 }
456 if (!current_parse_errors_.empty()) {
457 replace_status_ = "Resolve parse errors before switching messages";
459 find_panel_open_ = true;
460 return;
461 }
462 const std::vector<MessageRow> rows = CollectMessageRows(true);
463 if (rows.empty()) {
464 return;
465 }
466 int current = 0;
467 const int current_id = CurrentDisplayId();
468 for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
469 if (rows[i].display_id == current_id &&
470 rows[i].expanded == current_message_is_expanded_) {
471 current = i;
472 break;
473 }
474 }
475 const int next =
476 std::clamp(current + delta, 0, static_cast<int>(rows.size()) - 1);
477 if (next == current) {
478 return;
479 }
481 OpenMessageById(rows[next].display_id);
482}
483
484bool MessageEditor::ActivateFindMatch(const MessageRow& row, size_t offset,
485 bool wrapped) {
486 const bool same = row.display_id == CurrentDisplayId() &&
488 if (!same) {
489 if (!current_parse_errors_.empty()) {
490 replace_status_ = "Resolve parse errors before leaving this message";
492 return false;
493 }
495 if (!OpenMessageById(row.display_id)) {
496 replace_status_ = "Could not open the matching message";
498 return false;
499 }
500 }
501 find_match_start_ = offset;
502 find_has_match_ = true;
503 const int end = static_cast<int>(offset + search_text_.size());
504 pending_selection_start_ = static_cast<int>(offset);
506 editor_cursor_ = end;
508 replace_status_ = absl::StrFormat("Match in $%04X%s", row.display_id & 0xFFFF,
509 wrapped ? " (wrapped)" : "");
510 replace_status_error_ = false;
511 return true;
512}
513
514bool MessageEditor::AdvanceFind(bool forward) {
515 if (search_text_.empty()) {
516 replace_status_ = "Enter text to search";
518 return false;
519 }
520 const std::vector<MessageRow> rows = CollectMessageRows(false);
521 if (rows.empty()) {
522 replace_status_ = "No messages to search";
524 return false;
525 }
526
527 int start = 0;
528 const int current_id = CurrentDisplayId();
529 for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
530 if (rows[i].display_id == current_id &&
531 rows[i].expanded == current_message_is_expanded_) {
532 start = i;
533 break;
534 }
535 }
536
537 const int count = static_cast<int>(rows.size());
538 const size_t cursor = static_cast<size_t>(std::clamp(
539 editor_cursor_, 0, static_cast<int>(message_text_box_.text.size())));
540 const size_t forward_from = find_has_match_ ? find_match_start_ + 1 : cursor;
541 const size_t backward_before = find_has_match_ ? find_match_start_ : cursor;
542
543 auto match_at = [&](int row_index, size_t bound,
544 bool from_start) -> std::optional<size_t> {
545 const std::string_view text =
546 ContentsForDisplayId(rows[row_index].display_id);
547 if (forward) {
548 return FindNextTextMatch(text, search_text_, from_start ? 0 : bound,
550 }
551 const size_t before = from_start ? std::string::npos : bound;
554 };
555
556 if (auto found =
557 match_at(start, forward ? forward_from : backward_before, false)) {
558 return ActivateFindMatch(rows[start], *found, false);
559 }
560 for (int step = 1; step < count; ++step) {
561 const int row_index =
562 forward ? (start + step) % count : (start - step + count) % count;
563 if (auto found = match_at(row_index, 0, true)) {
564 return ActivateFindMatch(rows[row_index], *found, false);
565 }
566 }
567 // Forward wrap only matters once the cursor has moved past the start.
568 // Backward wrap also covers "previous" from the first match, which should
569 // land on the last match in this message.
570 const bool can_wrap = forward ? forward_from > 0 : true;
571 if (can_wrap) {
572 if (auto found = match_at(start, 0, true)) {
573 return ActivateFindMatch(rows[start], *found, true);
574 }
575 }
576 replace_status_ = "No matches";
578 return false;
579}
580
582 MessageData scratch;
583 const std::string optimized = scratch.OptimizeMessageForDictionary(
585 if (optimized == message_text_box_.text) {
586 replace_status_ = "Dictionary optimize made no changes";
587 replace_status_error_ = false;
588 find_panel_open_ = true;
589 return;
590 }
591 const auto parsed = ParseMessageToDataWithDiagnostics(optimized);
592 if (!parsed.ok()) {
593 replace_status_ = "Dictionary optimize produced invalid text";
595 find_panel_open_ = true;
596 return;
597 }
598 message_text_box_.text = optimized;
601 editor_cursor_ = 0;
602 ImGui::ClearActiveID();
605 replace_status_ = "Applied dictionary compression";
606 replace_status_error_ = false;
607 find_panel_open_ = true;
608}
609
611 if (!find_panel_open_) {
612 return;
613 }
614 ImGui::PushID("FindReplace");
615 if (find_focus_search_) {
616 ImGui::SetKeyboardFocusHere();
617 find_focus_search_ = false;
618 }
619 const float half =
620 std::max(80.0f, ImGui::GetContentRegionAvail().x * 0.5f - 8.0f);
621 ImGui::SetNextItemWidth(half);
622 if (ImGui::InputTextWithHint("##find", "Find in dialogue", &search_text_)) {
623 find_has_match_ = false;
624 }
625 ImGui::SameLine();
626 ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
627 ImGui::InputTextWithHint("##replace", "Replace with", &replace_text_);
628
629 if (ImGui::Button(tr("Find Next"))) {
630 AdvanceFind(true);
631 }
632 ImGui::SameLine();
633 if (ImGui::Button(tr("Previous"))) {
634 AdvanceFind(false);
635 }
636 ImGui::SameLine();
637 if (ImGui::Button(tr("Find All"))) {
638 if (search_text_.empty()) {
639 replace_status_ = "Enter text to search";
641 } else {
644 const int matches = static_cast<int>(CollectMessageRows(true).size());
645 replace_status_ = absl::StrFormat("List filtered to %d message%s",
646 matches, matches == 1 ? "" : "s");
647 replace_status_error_ = matches == 0;
651 "message.message_list");
652 }
653 }
654 }
655 ImGui::SameLine();
656 if (ImGui::Button(tr("Replace"))) {
657 const int count = ReplaceCurrentMatch();
658 if (count > 0) {
659 replace_status_ = "Replaced 1 occurrence";
660 replace_status_error_ = false;
661 } else if (search_text_.empty()) {
662 replace_status_ = "Enter text to search";
664 } else {
665 replace_status_ = "No match found in current message";
667 }
668 }
669 ImGui::SameLine();
670 if (ImGui::Button(tr("Replace All"))) {
671 int count = ReplaceAllMatches();
672 if (count >= 0) {
673 replace_status_ = absl::StrFormat("Replaced %d occurrence%s", count,
674 count == 1 ? "" : "s");
675 replace_status_error_ = count == 0;
676 }
677 }
678 ImGui::SameLine();
679 if (ImGui::SmallButton(tr("Close"))) {
680 find_panel_open_ = false;
681 }
682
683 ImGui::Checkbox(tr("Case Sensitive"), &case_sensitive_);
684 if (ImGui::IsItemHovered()) {
685 ImGui::SetTooltip("Applies to Find Next, Previous, and Replace.");
686 }
687 ImGui::SameLine();
688 ImGui::Checkbox(tr("Whole Word"), &match_whole_word_);
689 if (ImGui::IsItemHovered()) {
690 ImGui::SetTooltip("Match only when both sides are not letters or digits.");
691 }
692 if (!replace_status_.empty()) {
693 const ImVec4 color =
695 ImGui::TextColored(color, "%s", replace_status_.c_str());
696 }
697 ImGui::PopID();
698}
699
701 if (baseline_messages_.size() >= parsed_messages_.size()) {
702 return;
703 }
704 baseline_messages_.insert(
705 baseline_messages_.end(),
706 parsed_messages_.begin() +
707 static_cast<std::ptrdiff_t>(baseline_messages_.size()),
708 parsed_messages_.end());
709}
710
712 const int display_id = CurrentDisplayId();
713 if (display_id < 0 ||
714 display_id >= static_cast<int>(baseline_messages_.size())) {
715 return {};
716 }
717 return baseline_messages_[display_id];
718}
719
721 if (!current_parse_errors_.empty()) {
722 return;
723 }
724 const int display_id = CurrentDisplayId();
725 if (display_id < 0) {
726 return;
727 }
728 if (display_id >= static_cast<int>(baseline_messages_.size())) {
729 baseline_messages_.resize(static_cast<size_t>(display_id) + 1);
730 }
733}
734
736 const std::string reference = CurrentReferenceText();
737 if (reference != reference_view_text_) {
738 reference_view_text_ = reference;
739 }
740 const bool show_reference = show_reference_ && !reference_view_text_.empty();
741 const float label_h = ImGui::GetFrameHeightWithSpacing();
742 const float text_h = std::max(48.0f, height - label_h);
743
744 auto draw_editor = [&](float editor_height) {
745 MessageInputState input_state{&editor_cursor_, &pending_selection_start_,
747 if (InputTextMultiline("##MessageEditor", &message_text_box_.text,
748 ImVec2(-1.0f, editor_height),
749 ImGuiInputTextFlags_CallbackAlways,
750 MessageInputCallback, &input_state)) {
752 }
753 if (ImGui::IsItemDeactivatedAfterEdit()) {
755 }
756 };
757
758 if (!show_reference) {
759 draw_editor(-1.0f);
760 return;
761 }
762
763 const bool side_by_side = ImGui::GetContentRegionAvail().x >= 640.0f;
764 if (side_by_side &&
765 ImGui::BeginTable("##DialogueColumns", 2, ImGuiTableFlags_Resizable,
766 ImVec2(0.0f, height))) {
767 ImGui::TableSetupColumn(gui::ColumnLabel(tr("Loaded"), "loaded").c_str(),
768 ImGuiTableColumnFlags_WidthStretch, 0.45f);
769 ImGui::TableSetupColumn(gui::ColumnLabel(tr("Editing"), "editing").c_str(),
770 ImGuiTableColumnFlags_WidthStretch, 0.55f);
771 ImGui::TableNextRow();
772 ImGui::TableNextColumn();
773 ImGui::TextDisabled(tr("Loaded"));
774 ImGui::InputTextMultiline("##MessageReference", &reference_view_text_,
775 ImVec2(-1.0f, text_h),
776 ImGuiInputTextFlags_ReadOnly);
777 ImGui::TableNextColumn();
778 ImGui::TextDisabled(tr("Editing"));
779 draw_editor(text_h);
780 ImGui::EndTable();
781 return;
782 }
783
784 ImGui::TextDisabled(tr("Loaded"));
785 const float reference_h = std::clamp(height * 0.34f, 64.0f, 140.0f);
786 ImGui::InputTextMultiline("##MessageReference", &reference_view_text_,
787 ImVec2(-1.0f, reference_h),
788 ImGuiInputTextFlags_ReadOnly);
789 ImGui::TextDisabled(tr("Editing"));
790 draw_editor(std::max(48.0f, height - reference_h - label_h * 2.0f));
791}
792
794 const auto* project = dependencies_.project;
795 return project != nullptr && project->hack_manifest.loaded() &&
797}
798
802
806
807std::pair<int, int> MessageEditor::ExpandedInspectionRange() const {
808 const int fallback_start = GetExpandedTextDataStart();
809 const int fallback_end = GetExpandedTextDataEnd();
810 const auto* project = dependencies_.project;
811 if (project == nullptr || !project->hack_manifest.loaded()) {
812 return {fallback_start, fallback_end};
813 }
814 const auto& layout = project->hack_manifest.message_layout();
815 if (layout.data_start == 0 || layout.data_end < layout.data_start) {
816 return {fallback_start, fallback_end};
817 }
818 const int start = static_cast<int>(SnesToPc(layout.data_start));
819 const int end = static_cast<int>(SnesToPc(layout.data_end));
820 if (end < start) {
821 return {fallback_start, fallback_end};
822 }
823 return {start, end};
824}
825
827 if (source_sync_ready_) {
829 "The draft changed after preview. Preview again before publishing.";
831 }
832 source_sync_ready_ = false;
833 source_sync_changed_ = false;
836}
837
838absl::StatusOr<std::filesystem::path>
841 return absl::FailedPreconditionError(
842 "The project hack manifest does not define messages.source");
843 }
844 const auto& configured = dependencies_.project->hack_manifest.message_layout()
845 .source->canonical_bundle_path;
846 const std::filesystem::path relative(configured);
847 if (relative.empty() || relative.is_absolute() || relative.has_root_name()) {
848 return absl::InvalidArgumentError(
849 "Canonical message bundle must be a project-relative path");
850 }
851 std::error_code error;
852 const std::filesystem::path descriptor =
853 std::filesystem::absolute(dependencies_.project->filepath, error);
854 if (error) {
855 return absl::FailedPreconditionError(
856 absl::StrFormat("Cannot resolve project descriptor %s: %s",
857 dependencies_.project->filepath, error.message()));
858 }
859 const std::filesystem::path root =
860 std::filesystem::canonical(descriptor.parent_path(), error);
861 if (error) {
862 return absl::FailedPreconditionError(
863 absl::StrFormat("Cannot resolve project root for %s: %s",
864 dependencies_.project->filepath, error.message()));
865 }
866 const std::filesystem::path joined = (root / relative).lexically_normal();
867 const std::filesystem::path from_root = joined.lexically_relative(root);
868 if (from_root.empty() || from_root.is_absolute() ||
869 *from_root.begin() == "..") {
870 return absl::InvalidArgumentError(
871 "Canonical message bundle must stay inside the project root");
872 }
873 return joined;
874}
875
876absl::StatusOr<std::string> MessageEditor::HashFileSha256(
877 const std::filesystem::path& path) const {
878 std::ifstream input(path, std::ios::binary);
879 if (!input.is_open()) {
880 return absl::NotFoundError(absl::StrFormat(
881 "Cannot read canonical message bundle: %s", path.string()));
882 }
883 const std::string content((std::istreambuf_iterator<char>(input)),
884 std::istreambuf_iterator<char>());
885 if (!input.good() && !input.eof()) {
886 return absl::InternalError(absl::StrFormat(
887 "Cannot read canonical message bundle: %s", path.string()));
888 }
889 return ComputeMessageSourceSha256(content);
890}
891
892bool MessageEditor::IsCanonicalBundlePath(const std::string& path) const {
893 const auto canonical_or = ResolveCanonicalBundlePath();
894 if (!canonical_or.ok() || path.empty()) {
895 return false;
896 }
897 std::error_code error;
898 const auto incoming = std::filesystem::weakly_canonical(path, error);
899 if (error) {
900 return false;
901 }
902 const auto target =
903 std::filesystem::weakly_canonical(canonical_or.value(), error);
904 return !error && incoming == target;
905}
906
927
946
948 std::error_code error;
949 std::filesystem::path directory = std::filesystem::temp_directory_path(error);
950 if (error) {
951 directory = std::filesystem::current_path(error);
952 }
953 return (directory / absl::StrFormat("yaze-message-source-sync-%zu.json",
955 .string();
956}
957
959 source_sync_ready_ = false;
960 source_sync_changed_ = false;
963 source_sync_summary_.clear();
964 if (!current_parse_errors_.empty()) {
965 source_sync_status_ = "Resolve parse errors before previewing source";
967 return;
968 }
969 if (expanded_messages_.empty()) {
970 source_sync_status_ = "Load expanded messages before publishing source";
972 return;
973 }
974 if (!HasMessageSourceLayout()) {
976 "The project hack manifest does not define messages.source";
978 return;
979 }
980 if (source_loaded_canonical_sha_.empty()) {
982 "Load the canonical expanded bundle before previewing publication";
984 return;
985 }
986
987 const auto canonical_sha_or = [&]() -> absl::StatusOr<std::string> {
988 const auto path_or = ResolveCanonicalBundlePath();
989 if (!path_or.ok()) {
990 return path_or.status();
991 }
992 return HashFileSha256(path_or.value());
993 }();
994 if (!canonical_sha_or.ok()) {
995 source_sync_status_ = std::string(canonical_sha_or.status().message());
997 return;
998 }
999 if (canonical_sha_or.value() != source_loaded_canonical_sha_) {
1001 "Canonical source changed on disk after it was loaded. Reload the "
1002 "canonical bundle before preview. This draft was not published.";
1004 return;
1005 }
1006
1007 const std::string incoming_path = MessageSourceIncomingPath();
1008 const auto write_status =
1010 if (!write_status.ok()) {
1011 source_sync_status_ = absl::StrFormat(
1012 "Could not write the preview bundle: %s", write_status.message());
1014 return;
1015 }
1016 const auto draft_sha_or =
1017 HashFileSha256(std::filesystem::path(incoming_path));
1018 if (!draft_sha_or.ok()) {
1019 source_sync_status_ = std::string(draft_sha_or.status().message());
1021 return;
1022 }
1023
1024 auto result_or = SyncMessageSource(*dependencies_.project, incoming_path,
1026 if (!result_or.ok()) {
1027 source_sync_status_ = std::string(result_or.status().message());
1029 return;
1030 }
1031
1032 const MessageSourceSyncResult& result = *result_or;
1033 source_sync_ready_ = true;
1036 source_sync_draft_sha_ = draft_sha_or.value();
1037 source_sync_summary_ = absl::StrFormat(
1038 "%s. %d update%s, %zu / %zu bytes. SHA %s",
1039 result.changed ? "Source would change" : "Source already matches",
1040 result.incoming_updates, result.incoming_updates == 1 ? "" : "s",
1041 result.encoded_size, result.capacity,
1042 result.proposed_source_sha256.substr(
1043 0, std::min<size_t>(12, result.proposed_source_sha256.size())));
1046}
1047
1050 source_sync_draft_sha_.empty()) {
1051 source_sync_status_ = "Preview the publication before writing it";
1053 return;
1054 }
1055 if (!current_parse_errors_.empty() || expanded_messages_.empty() ||
1057 source_sync_status_ = "Publication is no longer valid";
1059 source_sync_ready_ = false;
1060 source_sync_draft_sha_.clear();
1061 return;
1062 }
1063
1064 const std::string incoming_path = MessageSourceIncomingPath();
1065 const auto write_status =
1067 if (!write_status.ok()) {
1068 source_sync_status_ = absl::StrFormat(
1069 "Could not write the publication bundle: %s", write_status.message());
1071 return;
1072 }
1073 const auto draft_sha_or =
1074 HashFileSha256(std::filesystem::path(incoming_path));
1075 if (!draft_sha_or.ok() || draft_sha_or.value() != source_sync_draft_sha_) {
1076 source_sync_ready_ = false;
1077 source_sync_draft_sha_.clear();
1079 "The draft changed after preview. Preview again before publishing. "
1080 "Nothing was written.";
1082 return;
1083 }
1084
1085 const auto canonical_path_or = ResolveCanonicalBundlePath();
1086 const auto canonical_sha_or =
1087 canonical_path_or.ok()
1088 ? HashFileSha256(canonical_path_or.value())
1089 : absl::StatusOr<std::string>(canonical_path_or.status());
1090 if (!canonical_sha_or.ok() ||
1091 canonical_sha_or.value() != source_sync_sha_before_ ||
1092 canonical_sha_or.value() != source_loaded_canonical_sha_) {
1093 source_sync_ready_ = false;
1094 source_sync_draft_sha_.clear();
1096 "Canonical source changed after preview. Reload the canonical bundle "
1097 "and preview again. This draft was not published.";
1099 return;
1100 }
1101
1103 options.write = true;
1105 auto result_or =
1106 SyncMessageSource(*dependencies_.project, incoming_path, options);
1107 source_sync_ready_ = false;
1108 source_sync_draft_sha_.clear();
1109 if (!result_or.ok()) {
1110 source_sync_status_ = std::string(result_or.status().message());
1112 return;
1113 }
1115 source_loaded_canonical_sha_ = result_or->proposed_source_sha256;
1116 source_sync_changed_ = false;
1118 result_or->wrote ? "Published the canonical bundle and ASM include. "
1119 "Rebuild before the patched ROM shows this text."
1120 : "Canonical source already matched; nothing written";
1123 absl::StrFormat("%s\n%s", result_or->canonical_bundle_path.string(),
1124 result_or->generated_asm_include_path.string());
1125}
1126
1130 ImGui::BeginDisabled(expanded_messages_.empty() ||
1131 !current_parse_errors_.empty());
1132 if (ImGui::Button(tr("Preview Publication"))) {
1134 }
1135 state.preview_disabled =
1136 (ImGui::GetItemFlags() & ImGuiItemFlags_Disabled) != 0;
1137 ImGui::EndDisabled();
1138 ImGui::SameLine();
1139 ImGui::BeginDisabled(!source_sync_ready_ || !source_sync_changed_);
1140 if (ImGui::Button(tr("Publish"))) {
1141 ImGui::OpenPopup("Publish message source###PublishMessageSource");
1142 }
1143 state.publish_disabled =
1144 (ImGui::GetItemFlags() & ImGuiItemFlags_Disabled) != 0;
1145 ImGui::EndDisabled();
1146 return state;
1147}
1148
1150 ImGui::Separator();
1152 if (!HasMessageSourceLayout()) {
1153 ImGui::TextWrapped(
1154 "%s",
1155 tr("Load a project whose hack manifest defines messages.source to "
1156 "publish expanded dialogue as a canonical bundle and ASM include."));
1157 return;
1158 }
1159
1160 const auto& source =
1162 ImGui::TextDisabled("%s", source.canonical_bundle_path.c_str());
1163 ImGui::TextDisabled("%s", source.generated_asm_include_path.c_str());
1164 ImGui::TextWrapped(
1165 "%s",
1166 tr("Preview checks capacity and the current source SHA without writing. "
1167 "Publish updates the bundle and generated include, not the ROM."));
1168
1170
1171 if (ImGui::BeginPopupModal("Publish message source###PublishMessageSource",
1172 nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
1173 ImGui::TextWrapped(
1174 "%s", tr("Write the expanded messages into the canonical bundle and "
1175 "generated ASM include. The ROM is not modified."));
1176 if (!source_sync_summary_.empty()) {
1177 ImGui::TextWrapped("%s", source_sync_summary_.c_str());
1178 }
1179 if (ImGui::Button(tr("Publish now"))) {
1181 ImGui::CloseCurrentPopup();
1182 }
1183 ImGui::SameLine();
1184 if (ImGui::Button(tr("Cancel"))) {
1185 ImGui::CloseCurrentPopup();
1186 }
1187 ImGui::EndPopup();
1188 }
1189
1190 if (!source_sync_status_.empty()) {
1191 const ImVec4 color = source_sync_status_error_ ? gui::GetErrorColor()
1193 ImGui::TextWrapped("%s", source_sync_status_.c_str());
1194 }
1195}
1196
1198 const bool can_navigate = current_parse_errors_.empty();
1199 ImGui::BeginDisabled(!can_navigate);
1200 if (ImGui::Button(ICON_MD_NAVIGATE_BEFORE "##prev_message")) {
1202 }
1203 ImGui::EndDisabled();
1204 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1205 ImGui::SetTooltip("Previous message (Alt+Up)");
1206 }
1207 ImGui::SameLine();
1208 ImGui::BeginDisabled(!can_navigate);
1209 if (ImGui::Button(ICON_MD_NAVIGATE_NEXT "##next_message")) {
1211 }
1212 ImGui::EndDisabled();
1213 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1214 ImGui::SetTooltip("Next message (Alt+Down)");
1215 }
1216
1217 ImGui::SameLine();
1218 ImGui::Text("$%04X", CurrentDisplayId() & 0xFFFF);
1219 ImGui::SameLine();
1221 current_message_is_expanded_ ? tr("Expanded") : tr("Vanilla"),
1224 ImGui::SameLine();
1225 ImGui::TextDisabled("%s", util::HexLong(current_message_.Address).c_str());
1226 ImGui::SameLine();
1227 ImGui::TextDisabled("%zu B", current_message_.Data.size());
1228
1229 ImGui::SameLine();
1230 const std::string reference_now = CurrentReferenceText();
1231 if (!reference_now.empty() && reference_now != message_text_box_.text) {
1233 ImGui::SameLine();
1234 }
1235 if (ImGui::Button(show_reference_ ? tr("Hide reference")
1236 : tr("Show reference"))) {
1238 }
1239 if (ImGui::IsItemHovered()) {
1240 ImGui::SetTooltip("Show the text from when this message was loaded.");
1241 }
1242 ImGui::SameLine();
1243 ImGui::BeginDisabled(reference_now == message_text_box_.text ||
1244 !current_parse_errors_.empty());
1245 if (ImGui::Button(tr("Accept"))) {
1247 }
1248 ImGui::EndDisabled();
1249 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1250 ImGui::SetTooltip(
1251 "Make the current draft the loaded reference for this message.");
1252 }
1253 ImGui::SameLine();
1254 if (ImGui::Button(ICON_MD_SEARCH "##toggle_find")) {
1257 }
1258 if (ImGui::IsItemHovered()) {
1259 ImGui::SetTooltip("Find and replace (F3 finds the next match)");
1260 }
1261 ImGui::SameLine();
1262 if (ImGui::Button(tr("Optimize"))) {
1264 }
1265 if (ImGui::IsItemHovered()) {
1266 ImGui::SetTooltip(
1267 "Replace dictionary phrases in this message with [D:XX] tokens.");
1268 }
1269}
1270
1272 gui::LayoutHelpers::BeginToolbar("##MessageListTools");
1274 const std::string path = util::FileDialogWrapper::ShowOpenFileDialog();
1275 if (!path.empty()) {
1277 }
1278 }
1279 if (ImGui::IsItemHovered()) {
1280 ImGui::SetTooltip("Import message bundle");
1281 }
1282 ImGui::SameLine();
1284 const std::string path = util::FileDialogWrapper::ShowSaveFileDialog();
1285 if (!path.empty()) {
1286 auto status =
1288 if (!status.ok()) {
1290 absl::StrFormat("Export failed: %s", status.message());
1292 } else {
1293 message_bundle_status_ = absl::StrFormat("Exported bundle: %s", path);
1295 }
1296 }
1297 }
1298 if (ImGui::IsItemHovered()) {
1299 ImGui::SetTooltip("Export message bundle");
1300 }
1302
1303 ImGui::SetNextItemWidth(
1304 std::max(120.0f, ImGui::GetContentRegionAvail().x - 120.0f));
1305 ImGui::InputTextWithHint("##message_filter", "Filter by text, ID, or address",
1306 &list_filter_);
1307 ImGui::SameLine();
1308 ImGui::SetNextItemWidth(110.0f);
1309 ImGui::Combo("##message_bank", &list_bank_filter_,
1310 "All\0Vanilla\0Expanded\0");
1311
1312 if (!message_bundle_status_.empty()) {
1313 const ImVec4 color = message_bundle_status_error_ ? gui::GetErrorColor()
1315 ImGui::TextWrapped("%s", message_bundle_status_.c_str());
1316 }
1317
1318 const std::vector<MessageRow> rows = CollectMessageRows(true);
1319 const int total = static_cast<int>(CollectMessageRows(false).size());
1320 ImGui::TextDisabled("%d / %d", static_cast<int>(rows.size()), total);
1321
1322 if (list_of_texts_.empty() && expanded_messages_.empty()) {
1324 "Load a ROM to edit dialogue.",
1325 nullptr, nullptr, true});
1326 return;
1327 }
1328 if (rows.empty()) {
1330 empty.icon = ICON_MD_SEARCH;
1331 empty.title = "No matches";
1332 empty.detail = "No messages match this filter.";
1333 empty.action_label = "Clear filter";
1334 empty.compact = true;
1335 empty.on_action = [this]() {
1336 list_filter_.clear();
1338 };
1339 gui::DrawEmptyState(empty);
1340 return;
1341 }
1342
1343 constexpr ImGuiTableFlags kListFlags =
1344 ImGuiTableFlags_Hideable | ImGuiTableFlags_Borders |
1345 ImGuiTableFlags_Resizable | ImGuiTableFlags_RowBg |
1346 ImGuiTableFlags_ScrollY;
1347 if (!BeginTable("##MessagesTable", 4, kListFlags, ImVec2(0, 0))) {
1348 return;
1349 }
1350 TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 64);
1351 TableSetupColumn("Bank", ImGuiTableColumnFlags_WidthFixed, 84);
1352 TableSetupColumn("Contents", ImGuiTableColumnFlags_WidthStretch);
1353 TableSetupColumn("Address", ImGuiTableColumnFlags_WidthFixed, 90);
1354 TableHeadersRow();
1355
1356 const int current_id = CurrentDisplayId();
1357 int target_row = -1;
1358 for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
1359 if (rows[i].display_id == current_id &&
1360 rows[i].expanded == current_message_is_expanded_) {
1361 target_row = i;
1362 break;
1363 }
1364 }
1365
1366 if (scroll_list_to_selection_ && target_row < 0) {
1368 }
1369 ImGuiListClipper clipper;
1370 clipper.Begin(static_cast<int>(rows.size()));
1371 if (scroll_list_to_selection_ && target_row >= 0) {
1372 clipper.IncludeItemByIndex(target_row);
1373 }
1374 while (clipper.Step()) {
1375 for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
1376 const MessageRow& message = rows[row];
1377 const bool selected = row == target_row;
1378 ImGui::TableNextRow();
1379 TableNextColumn();
1380 // Scope every interactive cell, including repeated bank badges, by the
1381 // message identity so filtering does not transfer IDs between rows.
1382 ImGui::PushID(message.expanded ? "expanded" : "vanilla");
1383 ImGui::PushID(message.display_id);
1384 char id_label[16];
1385 std::snprintf(id_label, sizeof(id_label), "$%04X",
1386 message.display_id & 0xFFFF);
1387 if (ImGui::Selectable(id_label, selected,
1388 ImGuiSelectableFlags_SpanAllColumns |
1389 ImGuiSelectableFlags_AllowOverlap)) {
1390 if (!selected && !current_parse_errors_.empty()) {
1391 replace_status_ = "Resolve parse errors before switching messages";
1392 replace_status_error_ = true;
1393 find_panel_open_ = true;
1394 } else if (!selected) {
1396 OpenMessageById(message.display_id);
1397 }
1398 }
1399 gui::ItemContextMenu(nullptr, [this, display_id = message.display_id,
1400 address = message.address]() {
1401 std::vector<gui::MenuItemSpec> items;
1402 auto open_item = gui::MenuItemSpec::Conditional(
1403 "Open Message",
1404 [this, display_id]() {
1405 FinalizePendingUndo();
1406 OpenMessageById(display_id);
1407 },
1408 [this]() { return current_parse_errors_.empty(); });
1409 open_item.icon = ICON_MD_ARROW_FORWARD;
1410 open_item.separator_after = true;
1411 items.push_back(std::move(open_item));
1412 items.push_back(gui::CopyToClipboardItem("Copy Message ID",
1413 util::HexWord(display_id)));
1414 if (display_id >= 0 &&
1415 display_id < static_cast<int>(parsed_messages_.size())) {
1416 items.push_back(gui::CopyToClipboardItem(
1417 "Copy Text", parsed_messages_[display_id]));
1418 }
1419 items.push_back(
1420 gui::CopyToClipboardItem("Copy Address", util::HexLong(address)));
1421 return items;
1422 });
1423 if (selected && scroll_list_to_selection_) {
1424 ImGui::SetScrollHereY(0.25f);
1426 }
1427 TableNextColumn();
1429 message.expanded ? tr("Expanded") : tr("Vanilla"),
1430 message.expanded ? gui::ButtonType::Warning : gui::ButtonType::Info);
1431
1432 TableNextColumn();
1433 const std::string preview =
1434 MessageListPreview(ContentsForDisplayId(message.display_id));
1435 ImGui::TextUnformatted(preview.c_str());
1436
1437 TableNextColumn();
1438 ImGui::TextUnformatted(util::HexLong(message.address).c_str());
1439 ImGui::PopID();
1440 ImGui::PopID();
1441 }
1442 }
1443 EndTable();
1444}
1445
1446void MessageEditor::DrawMessageStructure() {
1447 if (!ImGui::CollapsingHeader(tr("Message Structure"),
1448 ImGuiTreeNodeFlags_DefaultOpen)) {
1449 return;
1450 }
1451 ImGui::Text(tr("Lines: %d"), message_preview_.text_line + 1);
1452
1453 int scroll_count = 0;
1454 int current_line_chars = 0;
1455 int line_num = 0;
1456 const ImVec4 line_color = gui::GetSuccessColor();
1457
1458 for (size_t i = 0; i < current_message_.Data.size(); i++) {
1459 const uint8_t byte = current_message_.Data[i];
1460 if (byte == kScrollVertical) {
1461 scroll_count++;
1462 ImGui::TextColored(gui::GetInfoColor(),
1463 tr(" [V] Scroll at byte %zu (line %d, %d chars)"), i,
1464 line_num, current_line_chars);
1465 current_line_chars = 0;
1466 line_num++;
1467 } else if (byte == kLine1) {
1468 ImGui::TextColored(line_color, tr(" [1] Line 1 at byte %zu"), i);
1469 current_line_chars = 0;
1470 line_num = 0;
1471 } else if (byte == kLine2) {
1472 ImGui::TextColored(line_color, tr(" [2] Line 2 at byte %zu"), i);
1473 current_line_chars = 0;
1474 line_num = 1;
1475 } else if (byte == kLine3) {
1476 ImGui::TextColored(line_color, tr(" [3] Line 3 at byte %zu"), i);
1477 current_line_chars = 0;
1478 line_num = 2;
1479 } else if (byte < 100) {
1480 current_line_chars++;
1481 }
1482 }
1483
1484 if (scroll_count == 0) {
1485 ImGui::TextDisabled(tr("No scroll breaks in this message"));
1486 } else {
1487 ImGui::Text(tr("Total scroll breaks: %d"), scroll_count);
1488 }
1489
1490 ImGui::Separator();
1491 ImGui::TextDisabled(tr("Line width budget (max ~170px):"));
1492 const int estimated_line_width = current_line_chars * 8;
1493 const float width_ratio = static_cast<float>(estimated_line_width) / 170.0f;
1494 const ImVec4 width_color = (width_ratio > 1.0f) ? gui::GetErrorColor()
1495 : (width_ratio > 0.85f) ? gui::GetWarningColor()
1497 ImGui::TextColored(width_color, tr("Last line: ~%dpx / 170px (%d chars)"),
1498 estimated_line_width, current_line_chars);
1499}
1500
1501void MessageEditor::DrawCurrentMessage() {
1502 DrawMessageHeader();
1503 DrawFindBar();
1504
1505 const float available = ImGui::GetContentRegionAvail().y;
1506 const float max_editor = std::max(96.0f, available - 160.0f);
1507 editor_pane_height_ = std::clamp(editor_pane_height_, 96.0f, max_editor);
1508
1509 if (ImGui::BeginChild("##MessageTextPane", ImVec2(0, editor_pane_height_),
1510 true)) {
1511 DrawDialogueTextPane(ImGui::GetContentRegionAvail().y);
1512 }
1513 ImGui::EndChild();
1514
1515 ImGui::InvisibleButton("##message_editor_split", ImVec2(-1.0f, 6.0f));
1516 if (ImGui::IsItemHovered() || ImGui::IsItemActive()) {
1517 ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS);
1518 }
1519 if (ImGui::IsItemActive()) {
1520 editor_pane_height_ += ImGui::GetIO().MouseDelta.y;
1521 }
1522
1523 if (!current_parse_errors_.empty() || !current_parse_warnings_.empty()) {
1524 if (ImGui::BeginChild("##MessageDiagnostics", ImVec2(0, 72), true)) {
1525 if (!current_parse_errors_.empty()) {
1526 ImGui::TextColored(gui::GetErrorColor(), tr("Message parse errors"));
1527 for (const auto& error : current_parse_errors_) {
1528 ImGui::BulletText("%s", error.c_str());
1529 }
1530 }
1531 if (!current_parse_warnings_.empty()) {
1532 ImGui::TextColored(gui::GetWarningColor(),
1533 tr("Message parse warnings"));
1534 for (const auto& warning : current_parse_warnings_) {
1535 ImGui::BulletText("%s", warning.c_str());
1536 }
1537 }
1538 }
1539 ImGui::EndChild();
1540 }
1541 const auto line_warnings = ValidateMessageLineWidths(message_text_box_.text);
1542 if (!line_warnings.empty()) {
1543 ImGui::TextColored(gui::GetWarningColor(), tr("Line width warnings"));
1544 for (const auto& warning : line_warnings) {
1545 ImGui::BulletText("%s", warning.c_str());
1546 }
1547 }
1548
1549 gui::MemoryEditorPopup("Message Data", current_message_.Data);
1550
1551 if (ImGui::BeginChild("##MessagePreview", ImVec2(0, 0), true)) {
1552 EnsureFontTexturesReady();
1553 Text(tr("Message Preview"));
1554 ImGui::SameLine();
1555 if (Button(tr("View Palette"))) {
1556 ImGui::OpenPopup("Palette");
1557 }
1558 if (ImGui::BeginPopup("Palette")) {
1559 gui::DisplayPalette(font_preview_colors_, true);
1560 ImGui::EndPopup();
1561 }
1562 ImGui::SameLine();
1563 ImGui::TextDisabled(tr("Scroll the preview with the mouse wheel"));
1564
1566 BeginChild("CurrentGfxFont", ImVec2(0, 200), true,
1567 ImGuiWindowFlags_NoScrollWithMouse);
1568 current_font_gfx16_canvas_.GetConfig().role =
1570 current_font_gfx16_canvas_.DrawBackground();
1572 current_font_gfx16_canvas_.DrawContextMenu();
1573
1574 if (ImGui::IsWindowHovered()) {
1575 const float wheel = ImGui::GetIO().MouseWheel;
1576 if (wheel > 0 && message_preview_.shown_lines > 0) {
1577 message_preview_.shown_lines--;
1578 } else if (wheel < 0 && message_preview_.shown_lines <
1579 message_preview_.text_line - 2) {
1580 message_preview_.shown_lines++;
1581 }
1582 }
1583
1584 const ImVec2 preview_canvas_size = current_font_gfx16_canvas_.canvas_size();
1585 const float dest_width = std::max(0.0f, preview_canvas_size.x - 8.0f);
1586 const float dest_height = std::max(0.0f, preview_canvas_size.y - 8.0f);
1587 float src_height = 0.0f;
1588 if (dest_width > 0.0f && dest_height > 0.0f) {
1589 const float src_width =
1590 std::min(dest_width * 0.5f, static_cast<float>(kCurrentMessageWidth));
1591 src_height = std::min(dest_height * 0.5f,
1592 static_cast<float>(kCurrentMessageHeight));
1593 current_font_gfx16_canvas_.DrawBitmap(
1594 current_font_gfx16_bitmap_, ImVec2(0, 0),
1595 ImVec2(dest_width, dest_height),
1596 ImVec2(0, message_preview_.shown_lines * 16),
1597 ImVec2(src_width, src_height));
1598 }
1599
1600 {
1601 ImDrawList* overlay_draw_list = ImGui::GetWindowDrawList();
1602 const ImVec2 canvas_p0 = current_font_gfx16_canvas_.zero_point();
1603 const ImVec2 canvas_sz = current_font_gfx16_canvas_.canvas_size();
1604 const float line_height = 16.0f;
1605 float scale_y = 1.0f;
1606 if (dest_height > 0.0f && src_height > 0.0f) {
1607 scale_y = dest_height / src_height;
1608 }
1609 const ImU32 marker_color =
1610 ImGui::ColorConvertFloat4ToU32(gui::GetInfoColor());
1611 for (int marker_line : message_preview_.scroll_marker_lines) {
1612 const float src_y =
1613 (marker_line - message_preview_.shown_lines) * line_height;
1614 const float y = canvas_p0.y + src_y * scale_y;
1615 if (y >= canvas_p0.y && y <= canvas_p0.y + canvas_sz.y) {
1616 overlay_draw_list->AddLine(ImVec2(canvas_p0.x, y),
1617 ImVec2(canvas_p0.x + canvas_sz.x, y),
1618 marker_color, 1.5f);
1619 overlay_draw_list->AddText(
1620 ImVec2(canvas_p0.x + canvas_sz.x + 4, y - 6), marker_color,
1621 "[V]");
1622 }
1623 }
1624 }
1625
1626 current_font_gfx16_canvas_.DrawGrid();
1627 current_font_gfx16_canvas_.DrawOverlay();
1628 EndChild();
1629 DrawMessageStructure();
1630 }
1631 ImGui::EndChild();
1632}
1633
1634void MessageEditor::DrawFontAtlas() {
1635 gui::SectionHeader(ICON_MD_FONT_DOWNLOAD, tr("Font Atlas"),
1637 EnsureFontTexturesReady();
1638 font_gfx_canvas_.GetConfig().role = gui::CanvasRole::kSelectionSource;
1639 gui::BeginCanvas(font_gfx_canvas_, ImVec2(256, 256));
1640 font_gfx_canvas_.DrawBitmap(font_gfx_bitmap_, 0, 0, 2.0f);
1641 font_gfx_canvas_.DrawTileSelector(16, 32);
1642 gui::EndCanvas(font_gfx_canvas_);
1643 ImGui::TextDisabled(
1644 tr("2bpp dialogue font at $070000. Click a tile to inspect it."));
1645}
1646
1647void MessageEditor::DrawExpandedMessageSettings() {
1648 ImGui::Separator();
1649 gui::SectionHeader(ICON_MD_LIBRARY_ADD, tr("Expanded Messages"),
1651
1652 if (HasMessageSourceLayout()) {
1653 ImGui::TextWrapped(
1654 "%s",
1655 tr("Expanded dialogue loads automatically from the project bundle. "
1656 "Reload it after resolving a source error. Inspecting the built ROM "
1657 "uses the "
1658 "manifest data range and does not replace the canonical source."));
1659 if (ImGui::Button(tr("Load canonical source"))) {
1660 const auto status = LoadCanonicalExpandedSource();
1661 if (!status.ok()) {
1662 message_bundle_status_ = std::string(status.message());
1663 message_bundle_status_error_ = true;
1664 }
1665 }
1666 ImGui::SameLine();
1667 }
1668 if (ImGui::Button(HasMessageSourceLayout() ? tr("Inspect built ROM")
1669 : tr("Load from ROM"))) {
1670 auto status = LoadExpandedMessagesFromRom();
1671 if (!status.ok()) {
1672 LOG_WARN("MessageEditor", "Load from ROM: %s",
1673 std::string(status.message()).c_str());
1674 message_bundle_status_ =
1675 absl::StrFormat("Load from ROM failed: %s", status.message());
1676 message_bundle_status_error_ = true;
1677 }
1678 }
1679 ImGui::SameLine();
1680 if (ImGui::Button(tr("Load from File"))) {
1682 if (!path.empty()) {
1683 const auto status = LoadExpandedMessagesFromBin(path);
1684 if (!status.ok()) {
1685 message_bundle_status_ = std::string(status.message());
1686 message_bundle_status_error_ = true;
1687 if (auto* popup_manager = dependencies_.popup_manager) {
1688 popup_manager->Show("Error");
1689 }
1690 }
1691 }
1692 }
1693
1694 if (expanded_messages_.empty()) {
1695 ImGui::TextDisabled(
1696 tr("No expanded bank loaded. Vanilla dialogue is still editable."));
1697 return;
1698 }
1699
1700 ImGui::Text(tr("Source: %s"), expanded_message_path_.c_str());
1701 ImGui::Text(tr("Messages: %lu"), expanded_messages_.size());
1702
1703 const auto inspection_range = ExpandedInspectionRange();
1704 const int capacity = inspection_range.second - inspection_range.first + 1;
1705 const int used = CalculateExpandedBankUsage();
1706 const int remaining = capacity - used;
1707 const float usage_ratio =
1708 capacity > 0 ? static_cast<float>(used) / static_cast<float>(capacity)
1709 : 1.0f;
1710 const ImVec4 capacity_color = (usage_ratio < 0.75f) ? gui::GetSuccessColor()
1711 : (usage_ratio < 0.90f) ? gui::GetWarningColor()
1713 {
1714 gui::StyleColorGuard capacity_style(ImGuiCol_PlotHistogram, capacity_color);
1715 char capacity_label[64];
1716 std::snprintf(capacity_label, sizeof(capacity_label), "%d / %d bytes", used,
1717 capacity);
1718 ImGui::ProgressBar(std::clamp(usage_ratio, 0.0f, 1.0f), ImVec2(-1, 0),
1719 capacity_label);
1720 }
1721 ImGui::TextDisabled(tr("%d bytes free"), remaining);
1722
1723 if (ImGui::Button(tr("Add New Message"))) {
1724 MessageData new_message;
1725 new_message.ID = expanded_messages_.back().ID + 1;
1726 new_message.Address =
1727 expanded_messages_.back().Address +
1728 static_cast<int>(expanded_messages_.back().Data.size());
1729 expanded_messages_.push_back(new_message);
1730 MarkDomainDirty(SaveDomain::kExpandedMessages);
1731 const int display_id = expanded_message_base_id_ + new_message.ID;
1732 if (display_id >= 0 &&
1733 static_cast<size_t>(display_id) >= parsed_messages_.size()) {
1734 parsed_messages_.resize(display_id + 1);
1735 }
1736 if (current_parse_errors_.empty()) {
1737 FinalizePendingUndo();
1738 OpenMessageById(display_id);
1739 }
1740 }
1741 ImGui::SameLine();
1742 if (ImGui::Button(tr("Export to JSON"))) {
1744 if (!path.empty()) {
1745 PRINT_IF_ERROR(ExportMessagesToJson(path, expanded_messages_));
1746 }
1747 }
1748
1749 DrawMessageSourceSync();
1750}
1751
1752void MessageEditor::DrawTokenInserter(const std::vector<TextElement>& elements,
1753 std::string& filter, bool use_parameter) {
1754 ImGui::SetNextItemWidth(-1);
1755 ImGui::InputTextWithHint("##token_filter", "Filter tokens", &filter);
1756 if (!BeginTable("##TokenInserter", 2, kMessageTableFlags, ImVec2(0, 0))) {
1757 return;
1758 }
1759 TableSetupColumn("Token", ImGuiTableColumnFlags_WidthFixed, 110);
1760 TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch);
1761 TableHeadersRow();
1762 for (const auto& text_element : elements) {
1763 if (!filter.empty() &&
1764 !ContainsCaseInsensitive(text_element.GenericToken, filter) &&
1765 !ContainsCaseInsensitive(text_element.Description, filter)) {
1766 continue;
1767 }
1768 ImGui::TableNextRow();
1769 TableNextColumn();
1770 ImGui::PushID(text_element.ID);
1771 const std::string token =
1772 use_parameter ? text_element.GetParamToken(command_parameter_)
1773 : text_element.GenericToken;
1774 if (ImGui::Selectable(text_element.GenericToken.c_str(), false,
1775 ImGuiSelectableFlags_SpanAllColumns)) {
1776 InsertTokenAtCursor(token);
1777 }
1778 if (ImGui::IsItemHovered()) {
1779 ImGui::SetTooltip("Insert %s", token.c_str());
1780 }
1781 ImGui::PopID();
1782 TableNextColumn();
1783 TextWrapped("%s", text_element.Description.c_str());
1784 }
1785 EndTable();
1786}
1787
1788void MessageEditor::DrawTextCommands() {
1789 gui::InputHexByte("Command Parameter", &command_parameter_);
1790 if (ImGui::IsItemHovered()) {
1791 ImGui::SetTooltip("Argument byte for commands such as [W:02] or [SPD:00].");
1792 }
1793 DrawTokenInserter(TextCommands, command_filter_, true);
1794}
1795
1796void MessageEditor::DrawSpecialCharacters() {
1797 DrawTokenInserter(SpecialChars, symbol_filter_, false);
1798}
1799
1800void MessageEditor::DrawDictionary() {
1801 ImGui::SetNextItemWidth(-1);
1802 ImGui::InputTextWithHint("##dictionary_filter", "Filter dictionary",
1803 &dictionary_filter_);
1804 std::vector<int> visible;
1805 visible.reserve(message_preview_.all_dictionaries_.size());
1806 for (int i = 0;
1807 i < static_cast<int>(message_preview_.all_dictionaries_.size()); ++i) {
1808 const auto& dictionary = message_preview_.all_dictionaries_[i];
1809 if (!dictionary_filter_.empty() &&
1810 !ContainsCaseInsensitive(dictionary.Contents, dictionary_filter_) &&
1811 !ContainsCaseInsensitive(dictionary.Token, dictionary_filter_)) {
1812 continue;
1813 }
1814 visible.push_back(i);
1815 }
1816 if (visible.empty()) {
1817 ImGui::TextDisabled(tr("No dictionary entries match."));
1818 return;
1819 }
1820 if (!BeginTable("##Dictionary", 3,
1821 kMessageTableFlags | ImGuiTableFlags_ScrollY, ImVec2(0, 0))) {
1822 return;
1823 }
1824 TableSetupColumn("Token", ImGuiTableColumnFlags_WidthFixed, 72);
1825 TableSetupColumn("Text", ImGuiTableColumnFlags_WidthStretch);
1826 TableSetupColumn("Len", ImGuiTableColumnFlags_WidthFixed, 40);
1827 TableHeadersRow();
1828
1829 ImGuiListClipper clipper;
1830 clipper.Begin(static_cast<int>(visible.size()));
1831 while (clipper.Step()) {
1832 for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
1833 const auto& dictionary = message_preview_.all_dictionaries_[visible[row]];
1834 ImGui::TableNextRow();
1835 TableNextColumn();
1836 ImGui::PushID(dictionary.ID);
1837 if (ImGui::Selectable(dictionary.Token.c_str(), false,
1838 ImGuiSelectableFlags_SpanAllColumns)) {
1839 InsertTokenAtCursor(dictionary.Token);
1840 }
1841 if (ImGui::IsItemHovered()) {
1842 ImGui::SetTooltip("Insert %s", dictionary.Token.c_str());
1843 }
1844 ImGui::PopID();
1845 TableNextColumn();
1846 Text("%s", dictionary.Contents.c_str());
1847 TableNextColumn();
1848 Text("%d", dictionary.Length);
1849 }
1850 }
1851 EndTable();
1852}
1853
1854void MessageEditor::DrawMessageTools() {
1855 if (!ImGui::BeginTabBar("##MessageTools")) {
1856 return;
1857 }
1858 if (ImGui::BeginTabItem(tr("Commands"))) {
1859 DrawTextCommands();
1860 ImGui::EndTabItem();
1861 }
1862 if (ImGui::BeginTabItem(tr("Symbols"))) {
1863 DrawSpecialCharacters();
1864 ImGui::EndTabItem();
1865 }
1866 if (ImGui::BeginTabItem(tr("Dictionary"))) {
1867 DrawDictionary();
1868 ImGui::EndTabItem();
1869 }
1870 ImGui::EndTabBar();
1871}
1872void MessageEditor::UpdateCurrentMessageFromText(const std::string& text) {
1873 PushUndoSnapshot();
1874
1875 MarkDomainDirty(current_message_is_expanded_ ? SaveDomain::kExpandedMessages
1876 : SaveDomain::kVanillaMessages);
1877
1878 auto parse_result = ParseMessageToDataWithDiagnostics(text);
1879 current_parse_errors_ = parse_result.errors;
1880 current_parse_warnings_ = parse_result.warnings;
1881 if (!parse_result.ok()) {
1882 return;
1883 }
1884
1885 std::string raw_text = text;
1886 raw_text.erase(std::remove(raw_text.begin(), raw_text.end(), '\n'),
1887 raw_text.end());
1888
1889 current_message_.RawString = raw_text;
1890 current_message_.ContentsParsed = text;
1891 current_message_.Data = std::move(parse_result.bytes);
1892
1893 int parsed_index = current_message_index_;
1894 if (current_message_is_expanded_) {
1895 parsed_index = expanded_message_base_id_ + current_message_index_;
1896 }
1897
1898 if (parsed_index >= 0) {
1899 if (static_cast<size_t>(parsed_index) >= parsed_messages_.size()) {
1900 parsed_messages_.resize(parsed_index + 1);
1901 }
1902 parsed_messages_[parsed_index] = text;
1903 }
1904
1905 if (current_message_is_expanded_) {
1906 if (current_message_index_ >= 0 &&
1907 current_message_index_ < static_cast<int>(expanded_messages_.size())) {
1908 expanded_messages_[current_message_index_] = current_message_;
1909 }
1910 } else {
1911 if (current_message_index_ >= 0 &&
1912 current_message_index_ < static_cast<int>(list_of_texts_.size())) {
1913 list_of_texts_[current_message_index_] = current_message_;
1914 }
1915 }
1916
1917 DrawMessagePreview();
1918}
1919
1920void MessageEditor::ImportMessageBundleFromFile(const std::string& path) {
1921 message_bundle_status_.clear();
1922 message_bundle_status_error_ = false;
1923
1924 auto entries_or = LoadMessageBundleFromJson(path);
1925 if (!entries_or.ok()) {
1926 message_bundle_status_ =
1927 absl::StrFormat("Import failed: %s", entries_or.status().message());
1928 message_bundle_status_error_ = true;
1929 return;
1930 }
1931
1932 const ExpandedDraftSnapshot snapshot = CaptureExpandedDraft();
1933 const bool rom_dirty_before = rom_ && rom_->dirty();
1934 const bool importing_canonical = IsCanonicalBundlePath(path);
1935 std::string canonical_sha;
1936 if (importing_canonical) {
1937 const auto sha_or = HashFileSha256(std::filesystem::path(path));
1938 if (!sha_or.ok()) {
1939 message_bundle_status_ = std::string(sha_or.status().message());
1940 message_bundle_status_error_ = true;
1941 return;
1942 }
1943 canonical_sha = sha_or.value();
1944 }
1945
1946 int applied = 0;
1947 int errors = 0;
1948 int warnings = 0;
1949 int duplicate_errors = 0;
1950 int parse_error_entries = 0;
1951 int vanilla_updated = 0;
1952 int expanded_updated = 0;
1953 int expanded_created = 0;
1954 bool expanded_modified = false;
1955 std::vector<std::string> issue_samples;
1956
1957 auto add_issue_sample = [&issue_samples](const std::string& issue) {
1958 constexpr size_t kMaxIssueSamples = 4;
1959 if (issue_samples.size() < kMaxIssueSamples) {
1960 issue_samples.push_back(issue);
1961 }
1962 };
1963
1964 auto make_entry_key = [](const MessageBundleEntry& entry) {
1965 return absl::StrFormat("%s:%d", MessageBankToString(entry.bank), entry.id);
1966 };
1967
1968 std::unordered_map<std::string, int> seen_entries;
1969
1970 auto entries = entries_or.value();
1971 for (const auto& entry : entries) {
1972 const std::string entry_key = make_entry_key(entry);
1973 if (seen_entries.find(entry_key) != seen_entries.end()) {
1974 errors++;
1975 duplicate_errors++;
1976 add_issue_sample(absl::StrFormat("Duplicate entry for %s", entry_key));
1977 continue;
1978 }
1979 seen_entries.emplace(entry_key, 1);
1980
1981 auto parse_result = ParseMessageToDataWithDiagnostics(entry.text);
1982 auto line_warnings = ValidateMessageLineWidths(entry.text);
1983 warnings += static_cast<int>(parse_result.warnings.size());
1984 warnings += static_cast<int>(line_warnings.size());
1985
1986 if (!parse_result.ok()) {
1987 errors++;
1988 parse_error_entries++;
1989 if (!parse_result.errors.empty()) {
1990 add_issue_sample(absl::StrFormat("Parse error for %s: %s", entry_key,
1991 parse_result.errors.front()));
1992 } else {
1993 add_issue_sample(absl::StrFormat("Parse error for %s", entry_key));
1994 }
1995 continue;
1996 }
1997
1998 if (entry.bank == MessageBank::kVanilla) {
1999 if (entry.id < 0 || entry.id >= static_cast<int>(list_of_texts_.size())) {
2000 errors++;
2001 add_issue_sample(
2002 absl::StrFormat("Vanilla ID out of range: %d", entry.id));
2003 continue;
2004 }
2005 auto& message = list_of_texts_[entry.id];
2006 message.RawString = entry.text;
2007 message.ContentsParsed = entry.text;
2008 message.Data = parse_result.bytes;
2009 message.DataParsed = parse_result.bytes;
2010 if (entry.id >= 0 &&
2011 entry.id < static_cast<int>(parsed_messages_.size())) {
2012 parsed_messages_[entry.id] = entry.text;
2013 }
2014 vanilla_updated++;
2015 applied++;
2016 } else {
2017 if (entry.id < 0) {
2018 errors++;
2019 add_issue_sample(
2020 absl::StrFormat("Expanded ID out of range: %d", entry.id));
2021 continue;
2022 }
2023 if (entry.id >= static_cast<int>(expanded_messages_.size())) {
2024 const int old_size = static_cast<int>(expanded_messages_.size());
2025 const int target_size = entry.id + 1;
2026 expanded_messages_.resize(target_size);
2027 for (int i = old_size; i < target_size; ++i) {
2028 expanded_messages_[i].ID = i;
2029 }
2030 expanded_created += target_size - old_size;
2031 }
2032 auto& message = expanded_messages_[entry.id];
2033 message.RawString = entry.text;
2034 message.ContentsParsed = entry.text;
2035 message.Data = parse_result.bytes;
2036 message.DataParsed = parse_result.bytes;
2037 const int parsed_index = expanded_message_base_id_ + entry.id;
2038 if (parsed_index >= 0) {
2039 if (static_cast<size_t>(parsed_index) >= parsed_messages_.size()) {
2040 parsed_messages_.resize(parsed_index + 1);
2041 }
2042 parsed_messages_[parsed_index] = entry.text;
2043 }
2044 expanded_modified = true;
2045 expanded_updated++;
2046 applied++;
2047 }
2048 }
2049
2050 if (expanded_modified) {
2051 int pos = ExpandedInspectionRange().first;
2052 for (auto& message : expanded_messages_) {
2053 message.Address = pos;
2054 pos += static_cast<int>(message.Data.size()) + 1;
2055 }
2056 }
2057
2058 if (errors > 0) {
2059 message_bundle_status_ = absl::StrFormat(
2060 "Import finished with %d errors (%d applied: vanilla %d updated, "
2061 "expanded %d updated/%d created; %d warnings, %d duplicates, %d "
2062 "parse failures).",
2063 errors, applied, vanilla_updated, expanded_updated, expanded_created,
2064 warnings, duplicate_errors, parse_error_entries);
2065 if (!issue_samples.empty()) {
2066 message_bundle_status_ = absl::StrFormat(
2067 "%s Example: %s", message_bundle_status_, issue_samples.front());
2068 }
2069 message_bundle_status_error_ = true;
2070 } else {
2071 message_bundle_status_ = absl::StrFormat(
2072 "Imported %d messages (vanilla %d updated, expanded %d updated/%d "
2073 "created, %d warnings).",
2074 applied, vanilla_updated, expanded_updated, expanded_created, warnings);
2075 }
2076 if (errors > 0) {
2077 RestoreExpandedDraft(snapshot);
2078 if (rom_) {
2079 rom_->set_dirty(rom_dirty_before);
2080 }
2081 return;
2082 }
2083 int current_display_id = current_message_index_;
2084 if (current_message_is_expanded_) {
2085 current_display_id = expanded_message_base_id_ + current_message_index_;
2086 }
2087 if (current_display_id >= 0) {
2088 OpenMessageById(current_display_id);
2089 }
2090 if (vanilla_updated > 0) {
2091 MarkDomainDirty(SaveDomain::kVanillaMessages);
2092 }
2093 if (expanded_modified) {
2094 if (importing_canonical) {
2095 source_loaded_canonical_sha_ = canonical_sha;
2096 dirty_state_.expanded_messages = false;
2097 InvalidateSourcePreview();
2098 expanded_message_path_ = path;
2099 } else {
2100 source_loaded_canonical_sha_.clear();
2101 MarkDomainDirty(SaveDomain::kExpandedMessages);
2102 }
2103 }
2104}
2105
2106void MessageEditor::DrawMessagePreview() {
2107 // Render the message to the preview bitmap
2108 message_preview_.DrawMessagePreview(current_message_);
2109
2110 // Validate preview data before updating
2111 if (message_preview_.current_preview_data_.empty()) {
2112 LOG_WARN("MessageEditor", "Preview data is empty, skipping bitmap update");
2113 return;
2114 }
2115
2116 if (current_font_gfx16_bitmap_.is_active()) {
2117 // CRITICAL: Use set_data() to properly update both data_ AND surface_
2118 // mutable_data() returns a reference but doesn't update the surface!
2119 current_font_gfx16_bitmap_.set_data(message_preview_.current_preview_data_);
2120
2121 if (current_font_gfx16_bitmap_.palette().empty() &&
2122 !font_preview_colors_.empty()) {
2123 current_font_gfx16_bitmap_.SetPalette(font_preview_colors_);
2124 }
2125
2126 // Validate surface was updated
2127 if (!current_font_gfx16_bitmap_.surface()) {
2128 LOG_ERROR("MessageEditor", "Bitmap surface is null after set_data()");
2129 return;
2130 }
2131
2132 // Queue texture update (or create if missing) so changes are visible
2133 const auto command = current_font_gfx16_bitmap_.texture()
2136 gfx::Arena::Get().QueueTextureCommand(command, &current_font_gfx16_bitmap_);
2137
2138 LOG_DEBUG(
2139 "MessageEditor",
2140 "Updated message preview bitmap (size: %zu) and queued texture update",
2141 message_preview_.current_preview_data_.size());
2142 } else {
2143 // Create bitmap and queue texture creation with 8-bit indexed depth
2144 current_font_gfx16_bitmap_.Create(kCurrentMessageWidth,
2146 message_preview_.current_preview_data_);
2147 current_font_gfx16_bitmap_.SetPalette(font_preview_colors_);
2149 gfx::Arena::TextureCommandType::CREATE, &current_font_gfx16_bitmap_);
2150
2151 LOG_INFO("MessageEditor",
2152 "Created message preview bitmap (%dx%d) with 8-bit depth and "
2153 "queued texture creation",
2155 }
2156}
2157
2158absl::Status MessageEditor::Save() {
2159 if (!rom_ || !rom_->is_loaded()) {
2160 return absl::FailedPreconditionError("ROM not loaded");
2161 }
2162 if (!current_parse_errors_.empty()) {
2163 return absl::FailedPreconditionError(absl::StrFormat(
2164 "Current message has parse errors: %s", current_parse_errors_.front()));
2165 }
2166
2167 return SaveDirtyDomains(/*include_font_widths=*/true,
2168 /*include_vanilla_messages=*/true,
2169 /*include_expanded_messages=*/true);
2170}
2171
2172absl::StatusOr<MessageEditor::SavePlan> MessageEditor::BuildSavePlan(
2173 bool include_font_widths, bool include_vanilla_messages,
2174 bool include_expanded_messages) const {
2175 if (!rom_ || !rom_->is_loaded()) {
2176 return absl::FailedPreconditionError("ROM not loaded");
2177 }
2178
2179 SavePlan plan;
2180
2181 if (include_font_widths && dirty_state_.font_widths) {
2182 plan.saves_font_widths = true;
2183 plan.writes.push_back(
2184 {SaveDomain::kFontWidths, kCharactersWidth,
2185 std::vector<uint8_t>(message_preview_.width_array.begin(),
2186 message_preview_.width_array.end())});
2187 }
2188
2189 if (include_vanilla_messages && dirty_state_.vanilla_messages) {
2190 std::optional<size_t> expected_message_count;
2191 if (dependencies_.project &&
2192 dependencies_.project->hack_manifest.loaded()) {
2193 const int manifest_count =
2194 dependencies_.project->hack_manifest.message_layout().vanilla_count;
2195 if (manifest_count > 0) {
2196 expected_message_count = static_cast<size_t>(manifest_count);
2197 }
2198 }
2200 const VanillaMessageSavePlan vanilla_plan,
2201 BuildVanillaMessageSavePlan(list_of_texts_, expected_message_count));
2202
2203 plan.saves_vanilla_messages = true;
2204 for (const auto& write : vanilla_plan.writes()) {
2205 plan.writes.push_back(
2206 {SaveDomain::kVanillaMessages, write.start(), write.bytes()});
2207 }
2208 }
2209
2210 if (include_expanded_messages && dirty_state_.expanded_messages &&
2211 !expanded_messages_.empty()) {
2212 const auto range = ExpandedInspectionRange();
2213 const int start = range.first;
2214 const int end = range.second;
2215 if (start < 0 || end < start) {
2216 return absl::InvalidArgumentError("Invalid expanded message region");
2217 }
2218 if (static_cast<uint64_t>(start) >= rom_->size() ||
2219 static_cast<uint64_t>(end) >= rom_->size()) {
2220 return absl::OutOfRangeError(
2221 "Expanded message region is outside the ROM");
2222 }
2223
2224 const int64_t capacity = static_cast<int64_t>(end) - start + 1;
2225 std::vector<uint8_t> bytes;
2226 bytes.reserve(static_cast<size_t>(capacity));
2227 plan.expanded_message_addresses.reserve(expanded_messages_.size());
2228
2229 for (size_t index = 0; index < expanded_messages_.size(); ++index) {
2230 const auto& message = expanded_messages_[index];
2231 auto parsed = ParseMessageToDataWithDiagnostics(message.RawString);
2232 if (!parsed.ok()) {
2233 return absl::InvalidArgumentError(
2234 absl::StrFormat("Expanded message %d is invalid: %s",
2235 static_cast<int>(index), parsed.errors.front()));
2236 }
2237 if (message.RawString.find("[BANK]") != std::string::npos) {
2238 return absl::InvalidArgumentError(absl::StrFormat(
2239 "Expanded message %d contains [BANK], which is only valid in the "
2240 "vanilla message stream",
2241 static_cast<int>(index)));
2242 }
2243
2244 const int64_t needed = static_cast<int64_t>(parsed.bytes.size()) + 1;
2245 if (static_cast<int64_t>(bytes.size()) + needed + 1 > capacity) {
2246 return absl::ResourceExhaustedError(absl::StrFormat(
2247 "Expanded message data exceeds bank boundary "
2248 "(at message %d, used=%d, needed=%d, capacity=%d, end=0x%06X)",
2249 static_cast<int>(index), static_cast<int>(bytes.size()),
2250 static_cast<int>(needed), static_cast<int>(capacity), end));
2251 }
2252
2253 plan.expanded_message_addresses.push_back(start +
2254 static_cast<int>(bytes.size()));
2255 bytes.insert(bytes.end(), parsed.bytes.begin(), parsed.bytes.end());
2256 bytes.push_back(kMessageTerminator);
2257 }
2258 bytes.push_back(0xFF);
2259
2260 plan.saves_expanded_messages = true;
2261 plan.writes.push_back({SaveDomain::kExpandedMessages,
2262 static_cast<uint32_t>(start), std::move(bytes)});
2263 }
2264
2265 for (const auto& write : plan.writes) {
2266 const uint64_t end =
2267 static_cast<uint64_t>(write.start) + write.bytes.size();
2268 if (end > rom_->size() || end > std::numeric_limits<uint32_t>::max()) {
2269 return absl::OutOfRangeError(absl::StrFormat(
2270 "Message save range [0x%06X, 0x%06llX) is outside the ROM",
2271 write.start, static_cast<unsigned long long>(end)));
2272 }
2273 }
2274
2275 return plan;
2276}
2277
2278absl::Status MessageEditor::ValidateSavePlan(const SavePlan& plan) const {
2279 if (!dependencies_.project ||
2280 !dependencies_.project->hack_manifest.loaded() || plan.writes.empty()) {
2281 return absl::OkStatus();
2282 }
2283
2284 std::vector<std::pair<uint32_t, uint32_t>> ranges;
2285 ranges.reserve(plan.writes.size());
2286 for (const auto& write : plan.writes) {
2287 ranges.emplace_back(write.start, write.end());
2288 }
2289
2290 const auto& yaze_project = *dependencies_.project;
2291 if (yaze_project.rom_metadata.write_policy ==
2294 absl::StrContains(yaze_project.hack_manifest.hack_name(),
2295 "Oracle of Secrets")) {
2296 return absl::PermissionDeniedError(kOracleExpandedRomWriteRefusal);
2297 }
2298
2299 const auto status = ValidateHackManifestSaveConflicts(
2300 yaze_project.hack_manifest, yaze_project.rom_metadata.write_policy,
2301 ranges, "message data", "MessageEditor", dependencies_.toast_manager);
2302 return status;
2303}
2304
2305absl::Status MessageEditor::ApplySavePlan(const SavePlan& plan) {
2306 if (plan.writes.empty()) {
2307 return absl::OkStatus();
2308 }
2309
2311 for (const auto& write : plan.writes) {
2312 const char* label = "MessageData";
2313 switch (write.domain) {
2314 case SaveDomain::kFontWidths:
2315 label = "MessageFontWidths";
2316 break;
2317 case SaveDomain::kVanillaMessages:
2318 label = "VanillaMessageBank";
2319 break;
2320 case SaveDomain::kExpandedMessages:
2321 label = "ExpandedMessageBank";
2322 break;
2323 }
2324 RETURN_IF_ERROR(fence.Allow(write.start, write.end(), label));
2325 }
2326
2327 ScopedRomTransaction transaction(*rom_);
2328 yaze::rom::ScopedWriteFence fence_scope(rom_, &fence);
2329 for (const auto& write : plan.writes) {
2331 rom_->WriteVector(static_cast<int>(write.start), write.bytes));
2332 }
2333 transaction.Commit();
2334
2335 if (plan.saves_expanded_messages) {
2336 for (size_t index = 0; index < expanded_messages_.size() &&
2337 index < plan.expanded_message_addresses.size();
2338 ++index) {
2339 expanded_messages_[index].Address =
2340 plan.expanded_message_addresses[index];
2341 }
2342 SyncCurrentExpandedMessageAddress();
2343 }
2344
2345 return absl::OkStatus();
2346}
2347
2348absl::Status MessageEditor::SaveDirtyDomains(bool include_font_widths,
2349 bool include_vanilla_messages,
2350 bool include_expanded_messages) {
2351 ASSIGN_OR_RETURN(const SavePlan plan,
2352 BuildSavePlan(include_font_widths, include_vanilla_messages,
2353 include_expanded_messages));
2354 RETURN_IF_ERROR(ValidateSavePlan(plan));
2355 RETURN_IF_ERROR(ApplySavePlan(plan));
2356 RecordSavedDomains(plan);
2357 return absl::OkStatus();
2358}
2359
2360absl::Status MessageEditor::SaveExpandedMessages() {
2361 return SaveDirtyDomains(/*include_font_widths=*/false,
2362 /*include_vanilla_messages=*/false,
2363 /*include_expanded_messages=*/true);
2364}
2365
2366absl::Status MessageEditor::BeginSaveTransaction() {
2367 if (save_transaction_active_) {
2368 return absl::FailedPreconditionError(
2369 "Message save transaction is already active");
2370 }
2371 transaction_dirty_snapshot_ = dirty_state_;
2372 transaction_saved_domains_ = {};
2373 transaction_expanded_address_snapshot_.clear();
2374 transaction_expanded_address_snapshot_.reserve(expanded_messages_.size());
2375 for (const auto& message : expanded_messages_) {
2376 transaction_expanded_address_snapshot_.push_back(message.Address);
2377 }
2378 save_transaction_active_ = true;
2379 return absl::OkStatus();
2380}
2381
2382void MessageEditor::RollbackSaveTransaction() {
2383 if (!save_transaction_active_) {
2384 return;
2385 }
2386 dirty_state_ = transaction_dirty_snapshot_;
2387 for (size_t index = 0; index < expanded_messages_.size() &&
2388 index < transaction_expanded_address_snapshot_.size();
2389 ++index) {
2390 expanded_messages_[index].Address =
2391 transaction_expanded_address_snapshot_[index];
2392 }
2393 SyncCurrentExpandedMessageAddress();
2394 transaction_dirty_snapshot_ = {};
2395 transaction_saved_domains_ = {};
2396 transaction_expanded_address_snapshot_.clear();
2397 save_transaction_active_ = false;
2398}
2399
2400void MessageEditor::CommitSaveTransaction() {
2401 if (!save_transaction_active_) {
2402 return;
2403 }
2404 ClearSavedDomains(transaction_saved_domains_);
2405 transaction_dirty_snapshot_ = {};
2406 transaction_saved_domains_ = {};
2407 transaction_expanded_address_snapshot_.clear();
2408 save_transaction_active_ = false;
2409}
2410
2411void MessageEditor::MarkDomainDirty(SaveDomain domain) {
2412 bool* dirty = nullptr;
2413 bool* transaction_saved = nullptr;
2414 switch (domain) {
2415 case SaveDomain::kFontWidths:
2416 dirty = &dirty_state_.font_widths;
2417 transaction_saved = &transaction_saved_domains_.font_widths;
2418 break;
2419 case SaveDomain::kVanillaMessages:
2420 dirty = &dirty_state_.vanilla_messages;
2421 transaction_saved = &transaction_saved_domains_.vanilla_messages;
2422 break;
2423 case SaveDomain::kExpandedMessages:
2424 dirty = &dirty_state_.expanded_messages;
2425 transaction_saved = &transaction_saved_domains_.expanded_messages;
2426 break;
2427 }
2428 *dirty = true;
2429 if (save_transaction_active_) {
2430 *transaction_saved = false;
2431 }
2432 if (domain == SaveDomain::kExpandedMessages) {
2433 InvalidateSourcePreview();
2434 }
2435 if (rom_ && !(domain == SaveDomain::kExpandedMessages &&
2436 ExpandedEditsAreSourceOwned())) {
2437 rom_->set_dirty(true);
2438 }
2439}
2440
2441void MessageEditor::RecordSavedDomains(const SavePlan& plan) {
2444 if (!save_transaction_active_) {
2445 ClearSavedDomains(saved);
2446 return;
2447 }
2448 transaction_saved_domains_.font_widths |= saved.font_widths;
2449 transaction_saved_domains_.vanilla_messages |= saved.vanilla_messages;
2450 transaction_saved_domains_.expanded_messages |= saved.expanded_messages;
2451}
2452
2453void MessageEditor::ClearSavedDomains(const DirtyState& saved) {
2454 if (saved.font_widths) {
2455 dirty_state_.font_widths = false;
2456 }
2457 if (saved.vanilla_messages) {
2458 dirty_state_.vanilla_messages = false;
2459 }
2460 if (saved.expanded_messages) {
2461 dirty_state_.expanded_messages = false;
2462 }
2463}
2464
2465void MessageEditor::SyncCurrentExpandedMessageAddress() {
2466 if (!current_message_is_expanded_ || current_message_index_ < 0 ||
2467 current_message_index_ >= static_cast<int>(expanded_messages_.size())) {
2468 return;
2469 }
2470 current_message_.Address = expanded_messages_[current_message_index_].Address;
2471}
2472
2473absl::Status MessageEditor::LoadCanonicalExpandedSource() {
2474 if (!HasMessageSourceLayout()) {
2475 return absl::FailedPreconditionError(
2476 "The project hack manifest does not define messages.source");
2477 }
2478 if (dirty_state_.expanded_messages || dirty_state_.vanilla_messages ||
2479 dirty_state_.font_widths || pending_undo_before_.has_value() ||
2480 !current_parse_errors_.empty()) {
2481 return absl::FailedPreconditionError(
2482 "The Message editor has pending edits; canonical source was not "
2483 "loaded");
2484 }
2485 auto source_or = LoadMessageSource(*dependencies_.project);
2486 if (!source_or.ok()) {
2487 return source_or.status();
2488 }
2489 // Commit only after the complete source bank validates. This does not import
2490 // vanilla messages, set ROM dirty, write a preview, or publish source files.
2491 expanded_message_base_id_ = source_or->first_expanded_id;
2492 expanded_messages_ = std::move(source_or->messages);
2493 expanded_message_path_ = source_or->canonical_bundle_path.string();
2494 source_loaded_canonical_sha_ = std::move(source_or->source_sha256);
2495 parsed_messages_.resize(expanded_message_base_id_ +
2496 expanded_messages_.size());
2497 for (const auto& message : expanded_messages_) {
2498 parsed_messages_[expanded_message_base_id_ + message.ID] =
2499 message.RawString;
2500 }
2501 InvalidateSourcePreview();
2502 NoteLoadedMessages();
2503 message_bundle_status_ =
2504 absl::StrFormat("Loaded %zu expanded messages from project source: %s",
2505 expanded_messages_.size(), expanded_message_path_);
2506 message_bundle_status_error_ = false;
2507 if (current_message_is_expanded_) {
2508 OpenMessageById(expanded_message_base_id_ + current_message_index_);
2509 }
2510 return absl::OkStatus();
2511}
2512
2513absl::Status MessageEditor::LoadExpandedMessagesFromBin(
2514 const std::string& path) {
2515 const ExpandedDraftSnapshot snapshot = CaptureExpandedDraft();
2516 const bool rom_dirty_before = rom_ && rom_->dirty();
2517 expanded_message_base_id_ = ResolveExpandedMessageBaseId();
2518 parsed_messages_.resize(
2519 static_cast<size_t>(std::max(expanded_message_base_id_, 0)));
2520 expanded_messages_.clear();
2521 std::vector<std::string> parsed_expanded;
2522 std::string mutable_path = path;
2523 const auto status =
2524 LoadExpandedMessages(mutable_path, parsed_expanded, expanded_messages_,
2525 message_preview_.all_dictionaries_);
2526 if (!status.ok() || expanded_messages_.empty()) {
2527 RestoreExpandedDraft(snapshot);
2528 if (rom_) {
2529 rom_->set_dirty(rom_dirty_before);
2530 }
2531 if (!status.ok()) {
2532 return status;
2533 }
2534 return absl::NotFoundError("No expanded messages found in file");
2535 }
2536 parsed_messages_.insert(parsed_messages_.end(), parsed_expanded.begin(),
2537 parsed_expanded.end());
2538 expanded_message_path_ = path;
2539 source_loaded_canonical_sha_.clear();
2540 NoteLoadedMessages();
2541 MarkDomainDirty(SaveDomain::kExpandedMessages);
2542 return absl::OkStatus();
2543}
2544
2545absl::Status MessageEditor::LoadExpandedMessagesFromRom() {
2546 if (!rom_ || !rom_->is_loaded()) {
2547 return absl::FailedPreconditionError("ROM not loaded");
2548 }
2549
2550 const ExpandedDraftSnapshot snapshot = CaptureExpandedDraft();
2551 const bool rom_dirty_before = rom_ && rom_->dirty();
2552 const auto range = ExpandedInspectionRange();
2553 const int end = std::min(range.second, static_cast<int>(rom_->size()) - 1);
2554 auto loaded = ReadExpandedTextData(rom_->mutable_data(), range.first, end);
2555 if (loaded.empty()) {
2556 RestoreExpandedDraft(snapshot);
2557 if (rom_) {
2558 rom_->set_dirty(rom_dirty_before);
2559 }
2560 return absl::NotFoundError(
2561 "No expanded messages found in ROM at the manifest data range");
2562 }
2563
2564 expanded_message_base_id_ = ResolveExpandedMessageBaseId();
2565 parsed_messages_.resize(
2566 static_cast<size_t>(std::max(expanded_message_base_id_, 0)));
2567 expanded_messages_ = std::move(loaded);
2568 auto parsed_expanded =
2569 ParseMessageData(expanded_messages_, message_preview_.all_dictionaries_);
2570 for (const auto& msg : expanded_messages_) {
2571 if (msg.ID >= 0 && msg.ID < static_cast<int>(parsed_expanded.size())) {
2572 parsed_messages_.push_back(parsed_expanded[msg.ID]);
2573 }
2574 }
2575
2576 expanded_message_path_ = "(ROM inspection)";
2577 source_loaded_canonical_sha_.clear();
2578 dirty_state_.expanded_messages = false;
2579 InvalidateSourcePreview();
2580 NoteLoadedMessages();
2581 return absl::OkStatus();
2582}
2583
2584int MessageEditor::CalculateExpandedBankUsage() const {
2585 if (expanded_messages_.empty())
2586 return 0;
2587 int total = 0;
2588 for (const auto& msg : expanded_messages_) {
2589 total += static_cast<int>(msg.Data.size()) + 1; // +1 for 0x7F
2590 }
2591 total += 1; // +1 for final 0xFF
2592 return total;
2593}
2594
2595absl::Status MessageEditor::Cut() {
2596 // Ensure that text is currently selected in the text box.
2597 if (!message_text_box_.text.empty()) {
2598 // Cut the selected text in the control and paste it into the Clipboard.
2599 message_text_box_.Cut();
2600 }
2601 return absl::OkStatus();
2602}
2603
2604absl::Status MessageEditor::Paste() {
2605 // Determine if there is any text in the Clipboard to paste into the
2606 if (ImGui::GetClipboardText() != nullptr) {
2607 // Paste the text from the Clipboard into the text box.
2608 message_text_box_.Paste();
2609 }
2610 return absl::OkStatus();
2611}
2612
2613absl::Status MessageEditor::Copy() {
2614 // Ensure that text is selected in the text box.
2615 if (message_text_box_.selection_length > 0) {
2616 // Copy the selected text to the Clipboard.
2617 message_text_box_.Copy();
2618 }
2619 return absl::OkStatus();
2620}
2621
2622void MessageEditor::PushUndoSnapshot() {
2623 if (pending_undo_before_.has_value()) {
2624 // If we're still editing the same message, keep the existing "before"
2625 // snapshot so the entire edit session becomes a single undo step.
2626 if (pending_undo_before_->message_index == current_message_index_ &&
2627 pending_undo_before_->is_expanded == current_message_is_expanded_) {
2628 return;
2629 }
2630 FinalizePendingUndo();
2631 }
2632
2633 // Capture current state as "before"
2634 int parsed_index = current_message_index_;
2635 if (current_message_is_expanded_) {
2636 parsed_index = expanded_message_base_id_ + current_message_index_;
2637 }
2638 std::string text;
2639 if (parsed_index >= 0 &&
2640 parsed_index < static_cast<int>(parsed_messages_.size())) {
2641 text = parsed_messages_[parsed_index];
2642 }
2643 pending_undo_before_ =
2644 MessageSnapshot{current_message_, text, current_message_index_,
2645 current_message_is_expanded_};
2646}
2647
2648void MessageEditor::FinalizePendingUndo() {
2649 if (!pending_undo_before_.has_value())
2650 return;
2651
2652 // The "after" snapshot must correspond to the same message as the pending
2653 // "before", even if the user navigated to a different message in the UI.
2654 const int message_index = pending_undo_before_->message_index;
2655 const bool is_expanded = pending_undo_before_->is_expanded;
2656
2657 MessageData after_message;
2658 if (is_expanded) {
2659 if (message_index < 0 ||
2660 message_index >= static_cast<int>(expanded_messages_.size())) {
2661 pending_undo_before_.reset();
2662 return;
2663 }
2664 after_message = expanded_messages_[message_index];
2665 } else {
2666 if (message_index < 0 ||
2667 message_index >= static_cast<int>(list_of_texts_.size())) {
2668 pending_undo_before_.reset();
2669 return;
2670 }
2671 after_message = list_of_texts_[message_index];
2672 }
2673
2674 int parsed_index = message_index;
2675 if (is_expanded) {
2676 parsed_index = expanded_message_base_id_ + message_index;
2677 }
2678 std::string text;
2679 if (parsed_index >= 0 &&
2680 parsed_index < static_cast<int>(parsed_messages_.size())) {
2681 text = parsed_messages_[parsed_index];
2682 }
2683 MessageSnapshot after{std::move(after_message), std::move(text),
2684 message_index, is_expanded};
2685
2686 undo_manager_.Push(std::make_unique<MessageEditAction>(
2687 std::move(*pending_undo_before_), std::move(after),
2688 [this](const MessageSnapshot& s) { ApplySnapshot(s); }));
2689 pending_undo_before_.reset();
2690}
2691
2692void MessageEditor::ApplySnapshot(const MessageSnapshot& snapshot) {
2693 current_message_ = snapshot.message;
2694 current_message_index_ = snapshot.message_index;
2695 current_message_is_expanded_ = snapshot.is_expanded;
2696 message_text_box_.text = snapshot.parsed_text;
2697 const auto diagnostics =
2699 current_parse_errors_ = diagnostics.errors;
2700 current_parse_warnings_ = diagnostics.warnings;
2701
2702 int parsed_index = snapshot.message_index;
2703 if (snapshot.is_expanded) {
2704 parsed_index = expanded_message_base_id_ + snapshot.message_index;
2705 }
2706 if (parsed_index >= 0 &&
2707 parsed_index < static_cast<int>(parsed_messages_.size())) {
2708 parsed_messages_[parsed_index] = snapshot.parsed_text;
2709 }
2710
2711 if (snapshot.is_expanded) {
2712 if (snapshot.message_index >= 0 &&
2713 snapshot.message_index < static_cast<int>(expanded_messages_.size())) {
2714 expanded_messages_[snapshot.message_index] = snapshot.message;
2715 }
2716 } else {
2717 if (snapshot.message_index >= 0 &&
2718 snapshot.message_index < static_cast<int>(list_of_texts_.size())) {
2719 list_of_texts_[snapshot.message_index] = snapshot.message;
2720 }
2721 }
2722
2723 MarkDomainDirty(snapshot.is_expanded ? SaveDomain::kExpandedMessages
2724 : SaveDomain::kVanillaMessages);
2725 DrawMessagePreview();
2726}
2727
2728absl::Status MessageEditor::Undo() {
2729 FinalizePendingUndo();
2730 return undo_manager_.Undo();
2731}
2732
2733absl::Status MessageEditor::Redo() {
2734 return undo_manager_.Redo();
2735}
2736
2737void MessageEditor::Delete() {
2738 // Determine if any text is selected in the TextBox control.
2739 if (message_text_box_.selection_length == 0) {
2740 // clear all of the text in the textbox.
2741 message_text_box_.clear();
2742 }
2743}
2744
2745void MessageEditor::SelectAll() {
2746 // Determine if any text is selected in the TextBox control.
2747 if (message_text_box_.selection_length == 0) {
2748 // Select all text in the text box.
2749 message_text_box_.SelectAll();
2750
2751 // Move the cursor to the text box.
2752 message_text_box_.Focus();
2753 }
2754}
2755
2756absl::Status MessageEditor::Find() {
2757 // Edit > Find is a one-shot menu action. The bar itself is drawn every
2758 // frame from the message editor panel so it stays open across frames.
2759 find_panel_open_ = true;
2760 find_focus_search_ = true;
2761 if (dependencies_.window_manager) {
2762 dependencies_.window_manager->OpenWindow(dependencies_.session_id,
2763 "message.message_editor");
2764 }
2765 return absl::OkStatus();
2766}
2767
2768int MessageEditor::ReplaceCurrentMatch() {
2769 if (search_text_.empty()) {
2770 return 0;
2771 }
2772
2773 std::string& text = message_text_box_.text;
2774 const size_t cursor = static_cast<size_t>(
2775 std::clamp(editor_cursor_, 0, static_cast<int>(text.size())));
2776 const size_t from = find_has_match_ ? find_match_start_ : cursor;
2777 auto match = FindNextTextMatch(text, search_text_, from, case_sensitive_,
2778 match_whole_word_);
2779 if (!match && from > 0) {
2780 match = FindNextTextMatch(text, search_text_, 0, case_sensitive_,
2781 match_whole_word_);
2782 }
2783 if (!match) {
2784 return 0;
2785 }
2786
2787 text.replace(*match, search_text_.size(), replace_text_);
2788 pending_selection_start_ = static_cast<int>(*match);
2789 pending_selection_end_ =
2790 pending_selection_start_ + static_cast<int>(replace_text_.size());
2791 editor_cursor_ = pending_selection_end_;
2792 find_has_match_ = false;
2793 ImGui::ClearActiveID();
2794 UpdateCurrentMessageFromText(text);
2795 FinalizePendingUndo();
2796 return 1;
2797}
2798
2799int MessageEditor::ReplaceAllMatches() {
2800 if (search_text_.empty()) {
2801 return 0;
2802 }
2803 if (!current_parse_errors_.empty()) {
2804 replace_status_ =
2805 "Replace All blocked: resolve the current message parse errors first";
2806 replace_status_error_ = true;
2807 return -1;
2808 }
2809
2810 auto replace_in_text = [&](std::string& text) -> int {
2811 int count = 0;
2812 std::string search = search_text_;
2813
2814 if (!case_sensitive_) {
2815 std::transform(search.begin(), search.end(), search.begin(), ::tolower);
2816 }
2817
2818 size_t pos = 0;
2819 while (pos < text.size()) {
2820 std::string source = text;
2821 if (!case_sensitive_) {
2822 std::transform(source.begin(), source.end(), source.begin(), ::tolower);
2823 }
2824
2825 size_t found = source.find(search, pos);
2826 if (found == std::string::npos)
2827 break;
2828
2829 if (match_whole_word_) {
2830 bool start_ok = (found == 0 || !std::isalnum(source[found - 1]));
2831 bool end_ok = (found + search.size() >= source.size() ||
2832 !std::isalnum(source[found + search.size()]));
2833 if (!start_ok || !end_ok) {
2834 pos = found + 1;
2835 continue;
2836 }
2837 }
2838
2839 text.replace(found, search_text_.size(), replace_text_);
2840 pos = found + replace_text_.size();
2841 count++;
2842 }
2843 return count;
2844 };
2845
2846 struct PlannedReplacement {
2847 int message_index;
2848 bool is_expanded;
2849 std::string text;
2850 int count;
2851 };
2852 std::vector<PlannedReplacement> plan;
2853
2854 const auto plan_replacements = [&](const std::vector<MessageData>& messages,
2855 bool is_expanded) -> bool {
2856 for (size_t i = 0; i < messages.size(); ++i) {
2857 const int parsed_index =
2858 (is_expanded ? expanded_message_base_id_ : 0) + static_cast<int>(i);
2859 if (parsed_index < 0 ||
2860 parsed_index >= static_cast<int>(parsed_messages_.size())) {
2861 continue;
2862 }
2863
2864 std::string text = parsed_messages_[parsed_index];
2865 const int count = replace_in_text(text);
2866 if (count == 0) {
2867 continue;
2868 }
2869
2870 const auto parsed = ParseMessageToDataWithDiagnostics(text);
2871 if (!parsed.ok() ||
2872 (is_expanded && text.find("[BANK]") != std::string::npos)) {
2873 const std::string error =
2874 !parsed.ok() ? parsed.errors.front()
2875 : "[BANK] is not valid in expanded messages";
2876 replace_status_ = absl::StrFormat(
2877 "Replace All aborted at %s message %d: %s",
2878 is_expanded ? "expanded" : "vanilla", static_cast<int>(i), error);
2879 replace_status_error_ = true;
2880 return false;
2881 }
2882
2883 plan.push_back(
2884 {static_cast<int>(i), is_expanded, std::move(text), count});
2885 }
2886 return true;
2887 };
2888
2889 // Validate the complete batch before changing any message or undo state.
2890 if (!plan_replacements(list_of_texts_, false) ||
2891 !plan_replacements(expanded_messages_, true)) {
2892 return -1;
2893 }
2894
2895 const int previous_index = current_message_index_;
2896 const bool previous_expanded = current_message_is_expanded_;
2897 int total_replacements = 0;
2898
2899 for (const auto& replacement : plan) {
2900 current_message_ = replacement.is_expanded
2901 ? expanded_messages_[replacement.message_index]
2902 : list_of_texts_[replacement.message_index];
2903 current_message_index_ = replacement.message_index;
2904 current_message_is_expanded_ = replacement.is_expanded;
2905 message_text_box_.text = replacement.text;
2906 UpdateCurrentMessageFromText(replacement.text);
2907 FinalizePendingUndo();
2908 total_replacements += replacement.count;
2909 }
2910
2911 current_message_index_ = previous_index;
2912 current_message_is_expanded_ = previous_expanded;
2913
2914 // Refresh the current message's text box from updated data
2915 int current_parsed_idx = current_message_index_;
2916 if (current_message_is_expanded_) {
2917 current_parsed_idx = expanded_message_base_id_ + current_message_index_;
2918 }
2919 if (current_parsed_idx >= 0 &&
2920 current_parsed_idx < static_cast<int>(parsed_messages_.size())) {
2921 message_text_box_.text = parsed_messages_[current_parsed_idx];
2922 }
2923
2924 // Refresh current_message_ to reflect replacements
2925 if (current_message_is_expanded_) {
2926 if (current_message_index_ >= 0 &&
2927 current_message_index_ < static_cast<int>(expanded_messages_.size())) {
2928 current_message_ = expanded_messages_[current_message_index_];
2929 }
2930 } else {
2931 if (current_message_index_ >= 0 &&
2932 current_message_index_ < static_cast<int>(list_of_texts_.size())) {
2933 current_message_ = list_of_texts_[current_message_index_];
2934 }
2935 }
2936
2937 const auto current_diagnostics =
2938 ParseMessageToDataWithDiagnostics(message_text_box_.text);
2939 current_parse_errors_ = current_diagnostics.errors;
2940 current_parse_warnings_ = current_diagnostics.warnings;
2941
2942 return total_replacements;
2943}
2944
2945} // namespace editor
2946} // namespace yaze
auto data() const
Definition rom.h:213
auto size() const
Definition rom.h:212
bool is_loaded() const
Definition rom.h:199
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:262
project::YazeProject * project() const
Definition editor.h:370
zelda3::GameData * game_data() const
Definition editor.h:369
EditorDependencies dependencies_
Definition editor.h:382
MessageSourcePublishButtons DrawMessageSourcePublishButtons()
std::vector< std::string > baseline_messages_
void RestoreExpandedDraft(const ExpandedDraftSnapshot &snapshot)
std::vector< std::string > parsed_messages_
std::vector< MessageData > expanded_messages_
void ContributeStatus(StatusBar *status_bar) override
absl::Status Update() override
bool IsCanonicalBundlePath(const std::string &path) const
absl::Status LoadExpandedMessagesFromRom()
void DrawDialogueTextPane(float height)
void UpdateCurrentMessageFromText(const std::string &text)
std::vector< int > transaction_expanded_address_snapshot_
bool AdvanceFind(bool forward)
ExpandedDraftSnapshot CaptureExpandedDraft() const
bool HasUnpublishedMessageSourceChanges() const
absl::StatusOr< std::filesystem::path > ResolveCanonicalBundlePath() const
std::vector< std::string > current_parse_errors_
absl::Status Load() override
std::string MessageSourceIncomingPath() const
absl::StatusOr< std::string > HashFileSha256(const std::filesystem::path &path) const
std::vector< std::string > current_parse_warnings_
std::pair< int, int > ExpandedInspectionRange() const
absl::Status LoadCanonicalExpandedSource()
void InsertTokenAtCursor(const std::string &token)
std::vector< MessageRow > CollectMessageRows(bool apply_filter) const
bool ActivateFindMatch(const MessageRow &row, size_t offset, bool wrapped)
std::string_view ContentsForDisplayId(int display_id) const
std::vector< MessageData > list_of_texts_
std::string CurrentReferenceText() const
bool OpenMessageById(int display_id)
gfx::SnesPalette font_preview_colors_
void SelectAdjacentMessage(int delta)
void ImportMessageBundleFromFile(const std::string &path)
void SetGameData(zelda3::GameData *game_data) override
A session-aware status bar displayed at the bottom of the application.
Definition status_bar.h:57
void SetCustomSegment(const std::string &key, const std::string &value)
Set a custom segment with key-value pair.
void SetEditorMode(const std::string &mode)
Set the current editor mode or tool.
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:39
static Arena & Get()
Definition arena.cc:24
SnesPalette * mutable_palette()
Definition bitmap.h:394
RAII timer for automatic timing management.
static void BeginToolbar(const char *label)
static bool ToolbarButton(const char *label, const ImVec2 &size=ImVec2(0, 0))
RAII guard for ImGui style colors.
Definition style_guard.h:29
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 ICON_MD_CHAT
Definition icons.h:394
#define ICON_MD_SEARCH
Definition icons.h:1673
#define ICON_MD_NAVIGATE_BEFORE
Definition icons.h:1274
#define ICON_MD_ARROW_FORWARD
Definition icons.h:184
#define ICON_MD_PUBLISH
Definition icons.h:1526
#define ICON_MD_FONT_DOWNLOAD
Definition icons.h:818
#define ICON_MD_LIBRARY_ADD
Definition icons.h:1077
#define ICON_MD_UPLOAD
Definition icons.h:2048
#define ICON_MD_DOWNLOAD
Definition icons.h:618
#define ICON_MD_NAVIGATE_NEXT
Definition icons.h:1275
#define LOG_DEBUG(category, format,...)
Definition log.h:133
#define LOG_ERROR(category, format,...)
Definition log.h:140
#define LOG_WARN(category, format,...)
Definition log.h:138
#define LOG_INFO(category, format,...)
Definition log.h:136
#define PRINT_IF_ERROR(expression)
Definition macro.h:28
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
int MessageInputCallback(ImGuiInputTextCallbackData *data)
constexpr int kCharactersWidth
int GetExpandedTextDataStart()
constexpr uint8_t kScrollVertical
absl::StatusOr< MessageSourceSnapshot > LoadMessageSource(const project::YazeProject &project)
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
std::string MessageListPreview(std::string_view text, size_t max_chars=96)
constexpr int kTextData
std::string MessageBankToString(MessageBank bank)
constexpr int kCurrentMessageWidth
constexpr int kCurrentMessageHeight
constexpr uint8_t kLine2
absl::StatusOr< std::vector< MessageBundleEntry > > LoadMessageBundleFromJson(const std::string &path)
std::string ComputeMessageSourceSha256(std::string_view content)
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
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)
constexpr char kOracleExpandedRomWriteRefusal[]
std::vector< MessageData > ReadExpandedTextData(uint8_t *rom, int pos)
MessageParseResult ParseMessageToDataWithDiagnostics(std::string_view str)
int GetExpandedTextDataEnd()
absl::StatusOr< MessageSourceSyncResult > SyncMessageSource(const project::YazeProject &project, const fs::path &incoming_bundle_path, const MessageSourceSyncOptions &options)
std::optional< size_t > FindNextTextMatch(std::string_view text, std::string_view needle, size_t start, bool case_sensitive, bool whole_word)
std::optional< size_t > FindPreviousTextMatch(std::string_view text, std::string_view needle, size_t before, bool case_sensitive, bool whole_word)
std::vector< std::string > ValidateMessageLineWidths(const std::string &message)
bool MessageListQueryMatches(int display_id, uint32_t address, std::string_view contents, bool expanded, MessageBankFilter bank, std::string_view query)
constexpr uint8_t kLine3
void EndCanvas(Canvas &canvas)
std::string ColumnLabel(absl::string_view text, absl::string_view stable_id)
void BeginPadding(int i)
Definition style.cc:299
ImVec4 GetSuccessColor()
Definition ui_helpers.cc:49
void BeginCanvas(Canvas &canvas, ImVec2 child_size)
void MemoryEditorPopup(const std::string &label, std::span< uint8_t > memory)
Definition input.cc:815
void SectionHeader(const char *icon, const char *label, const ImVec4 &color)
void EndPadding()
Definition style.cc:303
bool ItemContextMenu(const char *id, const std::function< std::vector< MenuItemSpec >()> &build, ImGuiPopupFlags flags)
Right-click menu for the last submitted ImGui item.
bool DrawEmptyState(const EmptyStateOptions &options)
Draw a centered empty-state block.
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:263
MenuItemSpec CopyToClipboardItem(const std::string &label, const std::string &text)
ImVec4 GetInfoColor()
Definition ui_helpers.cc:64
bool InputHexByte(const char *label, uint8_t *data, float input_width, bool no_step)
Definition input.cc:483
void StatusBadge(const char *text, ButtonType type)
std::string HexWord(uint16_t word, HexStringParams params)
Definition hex.cc:44
std::string HexLong(uint32_t dword, HexStringParams params)
Definition hex.cc:55
uint32_t SnesToPc(uint32_t addr) noexcept
Definition snes.h:8
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::optional< Source > source
project::YazeProject * project
Definition editor.h:174
WorkspaceWindowManager * window_manager
Definition editor.h:182
std::vector< uint8_t > Data
std::string OptimizeMessageForDictionary(std::string_view message_string, const std::vector< DictionaryEntry > &dictionary)
std::vector< PlannedWrite > writes
std::array< uint8_t, kWidthArraySize > width_array
std::vector< DictionaryEntry > all_dictionaries_
Shared empty / disabled panel presentation.
Definition empty_state.h:15
std::function< void()> on_action
Definition empty_state.h:20
bool compact
Tighter vertical rhythm for drawers / inspectors.
Definition empty_state.h:22
std::string text
Definition style.h:50
core::HackManifest hack_manifest
Definition project.h:376