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(tr("Loaded"), ImGuiTableColumnFlags_WidthStretch,
768 0.45f);
769 ImGui::TableSetupColumn(tr("Editing"), ImGuiTableColumnFlags_WidthStretch,
770 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
803std::pair<int, int> MessageEditor::ExpandedInspectionRange() const {
804 const int fallback_start = GetExpandedTextDataStart();
805 const int fallback_end = GetExpandedTextDataEnd();
806 const auto* project = dependencies_.project;
807 if (project == nullptr || !project->hack_manifest.loaded()) {
808 return {fallback_start, fallback_end};
809 }
810 const auto& layout = project->hack_manifest.message_layout();
811 if (layout.data_start == 0 || layout.data_end < layout.data_start) {
812 return {fallback_start, fallback_end};
813 }
814 const int start = static_cast<int>(SnesToPc(layout.data_start));
815 const int end = static_cast<int>(SnesToPc(layout.data_end));
816 if (end < start) {
817 return {fallback_start, fallback_end};
818 }
819 return {start, end};
820}
821
823 if (source_sync_ready_) {
825 "The draft changed after preview. Preview again before publishing.";
827 }
828 source_sync_ready_ = false;
829 source_sync_changed_ = false;
832}
833
834absl::StatusOr<std::filesystem::path>
837 return absl::FailedPreconditionError(
838 "The project hack manifest does not define messages.source");
839 }
840 const auto& configured = dependencies_.project->hack_manifest.message_layout()
841 .source->canonical_bundle_path;
842 const std::filesystem::path relative(configured);
843 if (relative.empty() || relative.is_absolute() || relative.has_root_name()) {
844 return absl::InvalidArgumentError(
845 "Canonical message bundle must be a project-relative path");
846 }
847 std::error_code error;
848 const std::filesystem::path descriptor =
849 std::filesystem::absolute(dependencies_.project->filepath, error);
850 if (error) {
851 return absl::FailedPreconditionError(
852 absl::StrFormat("Cannot resolve project descriptor %s: %s",
853 dependencies_.project->filepath, error.message()));
854 }
855 const std::filesystem::path root =
856 std::filesystem::canonical(descriptor.parent_path(), error);
857 if (error) {
858 return absl::FailedPreconditionError(
859 absl::StrFormat("Cannot resolve project root for %s: %s",
860 dependencies_.project->filepath, error.message()));
861 }
862 const std::filesystem::path joined = (root / relative).lexically_normal();
863 const std::filesystem::path from_root = joined.lexically_relative(root);
864 if (from_root.empty() || from_root.is_absolute() ||
865 *from_root.begin() == "..") {
866 return absl::InvalidArgumentError(
867 "Canonical message bundle must stay inside the project root");
868 }
869 return joined;
870}
871
872absl::StatusOr<std::string> MessageEditor::HashFileSha256(
873 const std::filesystem::path& path) const {
874 std::ifstream input(path, std::ios::binary);
875 if (!input.is_open()) {
876 return absl::NotFoundError(absl::StrFormat(
877 "Cannot read canonical message bundle: %s", path.string()));
878 }
879 const std::string content((std::istreambuf_iterator<char>(input)),
880 std::istreambuf_iterator<char>());
881 if (!input.good() && !input.eof()) {
882 return absl::InternalError(absl::StrFormat(
883 "Cannot read canonical message bundle: %s", path.string()));
884 }
885 return ComputeMessageSourceSha256(content);
886}
887
888bool MessageEditor::IsCanonicalBundlePath(const std::string& path) const {
889 const auto canonical_or = ResolveCanonicalBundlePath();
890 if (!canonical_or.ok() || path.empty()) {
891 return false;
892 }
893 std::error_code error;
894 const auto incoming = std::filesystem::weakly_canonical(path, error);
895 if (error) {
896 return false;
897 }
898 const auto target =
899 std::filesystem::weakly_canonical(canonical_or.value(), error);
900 return !error && incoming == target;
901}
902
923
942
944 std::error_code error;
945 std::filesystem::path directory = std::filesystem::temp_directory_path(error);
946 if (error) {
947 directory = std::filesystem::current_path(error);
948 }
949 return (directory / absl::StrFormat("yaze-message-source-sync-%zu.json",
951 .string();
952}
953
955 source_sync_ready_ = false;
956 source_sync_changed_ = false;
959 source_sync_summary_.clear();
960 if (!current_parse_errors_.empty()) {
961 source_sync_status_ = "Resolve parse errors before previewing source";
963 return;
964 }
965 if (expanded_messages_.empty()) {
966 source_sync_status_ = "Load expanded messages before publishing source";
968 return;
969 }
970 if (!HasMessageSourceLayout()) {
972 "The project hack manifest does not define messages.source";
974 return;
975 }
976 if (source_loaded_canonical_sha_.empty()) {
978 "Load the canonical expanded bundle before previewing publication";
980 return;
981 }
982
983 const auto canonical_sha_or = [&]() -> absl::StatusOr<std::string> {
984 const auto path_or = ResolveCanonicalBundlePath();
985 if (!path_or.ok()) {
986 return path_or.status();
987 }
988 return HashFileSha256(path_or.value());
989 }();
990 if (!canonical_sha_or.ok()) {
991 source_sync_status_ = std::string(canonical_sha_or.status().message());
993 return;
994 }
995 if (canonical_sha_or.value() != source_loaded_canonical_sha_) {
997 "Canonical source changed on disk after it was loaded. Reload the "
998 "canonical bundle before preview. This draft was not published.";
1000 return;
1001 }
1002
1003 const std::string incoming_path = MessageSourceIncomingPath();
1004 const auto write_status =
1006 if (!write_status.ok()) {
1007 source_sync_status_ = absl::StrFormat(
1008 "Could not write the preview bundle: %s", write_status.message());
1010 return;
1011 }
1012 const auto draft_sha_or =
1013 HashFileSha256(std::filesystem::path(incoming_path));
1014 if (!draft_sha_or.ok()) {
1015 source_sync_status_ = std::string(draft_sha_or.status().message());
1017 return;
1018 }
1019
1020 auto result_or = SyncMessageSource(*dependencies_.project, incoming_path,
1022 if (!result_or.ok()) {
1023 source_sync_status_ = std::string(result_or.status().message());
1025 return;
1026 }
1027
1028 const MessageSourceSyncResult& result = *result_or;
1029 source_sync_ready_ = true;
1032 source_sync_draft_sha_ = draft_sha_or.value();
1033 source_sync_summary_ = absl::StrFormat(
1034 "%s. %d update%s, %zu / %zu bytes. SHA %s",
1035 result.changed ? "Source would change" : "Source already matches",
1036 result.incoming_updates, result.incoming_updates == 1 ? "" : "s",
1037 result.encoded_size, result.capacity,
1038 result.proposed_source_sha256.substr(
1039 0, std::min<size_t>(12, result.proposed_source_sha256.size())));
1042}
1043
1046 source_sync_draft_sha_.empty()) {
1047 source_sync_status_ = "Preview the publication before writing it";
1049 return;
1050 }
1051 if (!current_parse_errors_.empty() || expanded_messages_.empty() ||
1053 source_sync_status_ = "Publication is no longer valid";
1055 source_sync_ready_ = false;
1056 source_sync_draft_sha_.clear();
1057 return;
1058 }
1059
1060 const std::string incoming_path = MessageSourceIncomingPath();
1061 const auto write_status =
1063 if (!write_status.ok()) {
1064 source_sync_status_ = absl::StrFormat(
1065 "Could not write the publication bundle: %s", write_status.message());
1067 return;
1068 }
1069 const auto draft_sha_or =
1070 HashFileSha256(std::filesystem::path(incoming_path));
1071 if (!draft_sha_or.ok() || draft_sha_or.value() != source_sync_draft_sha_) {
1072 source_sync_ready_ = false;
1073 source_sync_draft_sha_.clear();
1075 "The draft changed after preview. Preview again before publishing. "
1076 "Nothing was written.";
1078 return;
1079 }
1080
1081 const auto canonical_path_or = ResolveCanonicalBundlePath();
1082 const auto canonical_sha_or =
1083 canonical_path_or.ok()
1084 ? HashFileSha256(canonical_path_or.value())
1085 : absl::StatusOr<std::string>(canonical_path_or.status());
1086 if (!canonical_sha_or.ok() ||
1087 canonical_sha_or.value() != source_sync_sha_before_ ||
1088 canonical_sha_or.value() != source_loaded_canonical_sha_) {
1089 source_sync_ready_ = false;
1090 source_sync_draft_sha_.clear();
1092 "Canonical source changed after preview. Reload the canonical bundle "
1093 "and preview again. This draft was not published.";
1095 return;
1096 }
1097
1099 options.write = true;
1101 auto result_or =
1102 SyncMessageSource(*dependencies_.project, incoming_path, options);
1103 source_sync_ready_ = false;
1104 source_sync_draft_sha_.clear();
1105 if (!result_or.ok()) {
1106 source_sync_status_ = std::string(result_or.status().message());
1108 return;
1109 }
1111 source_loaded_canonical_sha_ = result_or->proposed_source_sha256;
1112 source_sync_changed_ = false;
1114 result_or->wrote ? "Published the canonical bundle and ASM include. "
1115 "Rebuild before the patched ROM shows this text."
1116 : "Canonical source already matched; nothing written";
1119 absl::StrFormat("%s\n%s", result_or->canonical_bundle_path.string(),
1120 result_or->generated_asm_include_path.string());
1121}
1122
1126 ImGui::BeginDisabled(expanded_messages_.empty() ||
1127 !current_parse_errors_.empty());
1128 if (ImGui::Button(tr("Preview Publication"))) {
1130 }
1131 state.preview_disabled =
1132 (ImGui::GetItemFlags() & ImGuiItemFlags_Disabled) != 0;
1133 ImGui::EndDisabled();
1134 ImGui::SameLine();
1135 ImGui::BeginDisabled(!source_sync_ready_ || !source_sync_changed_);
1136 if (ImGui::Button(tr("Publish"))) {
1137 ImGui::OpenPopup("Publish message source###PublishMessageSource");
1138 }
1139 state.publish_disabled =
1140 (ImGui::GetItemFlags() & ImGuiItemFlags_Disabled) != 0;
1141 ImGui::EndDisabled();
1142 return state;
1143}
1144
1146 ImGui::Separator();
1148 if (!HasMessageSourceLayout()) {
1149 ImGui::TextWrapped(
1150 "%s",
1151 tr("Load a project whose hack manifest defines messages.source to "
1152 "publish expanded dialogue as a canonical bundle and ASM include."));
1153 return;
1154 }
1155
1156 const auto& source =
1158 ImGui::TextDisabled("%s", source.canonical_bundle_path.c_str());
1159 ImGui::TextDisabled("%s", source.generated_asm_include_path.c_str());
1160 ImGui::TextWrapped(
1161 "%s",
1162 tr("Preview checks capacity and the current source SHA without writing. "
1163 "Publish updates the bundle and generated include, not the ROM."));
1164
1166
1167 if (ImGui::BeginPopupModal("Publish message source###PublishMessageSource",
1168 nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
1169 ImGui::TextWrapped(
1170 "%s", tr("Write the expanded messages into the canonical bundle and "
1171 "generated ASM include. The ROM is not modified."));
1172 if (!source_sync_summary_.empty()) {
1173 ImGui::TextWrapped("%s", source_sync_summary_.c_str());
1174 }
1175 if (ImGui::Button(tr("Publish now"))) {
1177 ImGui::CloseCurrentPopup();
1178 }
1179 ImGui::SameLine();
1180 if (ImGui::Button(tr("Cancel"))) {
1181 ImGui::CloseCurrentPopup();
1182 }
1183 ImGui::EndPopup();
1184 }
1185
1186 if (!source_sync_status_.empty()) {
1187 const ImVec4 color = source_sync_status_error_ ? gui::GetErrorColor()
1189 ImGui::TextWrapped("%s", source_sync_status_.c_str());
1190 }
1191}
1192
1194 const bool can_navigate = current_parse_errors_.empty();
1195 ImGui::BeginDisabled(!can_navigate);
1196 if (ImGui::Button(ICON_MD_NAVIGATE_BEFORE "##prev_message")) {
1198 }
1199 ImGui::EndDisabled();
1200 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1201 ImGui::SetTooltip("Previous message (Alt+Up)");
1202 }
1203 ImGui::SameLine();
1204 ImGui::BeginDisabled(!can_navigate);
1205 if (ImGui::Button(ICON_MD_NAVIGATE_NEXT "##next_message")) {
1207 }
1208 ImGui::EndDisabled();
1209 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1210 ImGui::SetTooltip("Next message (Alt+Down)");
1211 }
1212
1213 ImGui::SameLine();
1214 ImGui::Text("$%04X", CurrentDisplayId() & 0xFFFF);
1215 ImGui::SameLine();
1217 current_message_is_expanded_ ? tr("Expanded") : tr("Vanilla"),
1220 ImGui::SameLine();
1221 ImGui::TextDisabled("%s", util::HexLong(current_message_.Address).c_str());
1222 ImGui::SameLine();
1223 ImGui::TextDisabled("%zu B", current_message_.Data.size());
1224
1225 ImGui::SameLine();
1226 const std::string reference_now = CurrentReferenceText();
1227 if (!reference_now.empty() && reference_now != message_text_box_.text) {
1229 ImGui::SameLine();
1230 }
1231 if (ImGui::Button(show_reference_ ? tr("Hide reference")
1232 : tr("Show reference"))) {
1234 }
1235 if (ImGui::IsItemHovered()) {
1236 ImGui::SetTooltip("Show the text from when this message was loaded.");
1237 }
1238 ImGui::SameLine();
1239 ImGui::BeginDisabled(reference_now == message_text_box_.text ||
1240 !current_parse_errors_.empty());
1241 if (ImGui::Button(tr("Accept"))) {
1243 }
1244 ImGui::EndDisabled();
1245 if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
1246 ImGui::SetTooltip(
1247 "Make the current draft the loaded reference for this message.");
1248 }
1249 ImGui::SameLine();
1250 if (ImGui::Button(ICON_MD_SEARCH "##toggle_find")) {
1253 }
1254 if (ImGui::IsItemHovered()) {
1255 ImGui::SetTooltip("Find and replace (F3 finds the next match)");
1256 }
1257 ImGui::SameLine();
1258 if (ImGui::Button(tr("Optimize"))) {
1260 }
1261 if (ImGui::IsItemHovered()) {
1262 ImGui::SetTooltip(
1263 "Replace dictionary phrases in this message with [D:XX] tokens.");
1264 }
1265}
1266
1268 gui::LayoutHelpers::BeginToolbar("##MessageListTools");
1270 const std::string path = util::FileDialogWrapper::ShowOpenFileDialog();
1271 if (!path.empty()) {
1273 }
1274 }
1275 if (ImGui::IsItemHovered()) {
1276 ImGui::SetTooltip("Import message bundle");
1277 }
1278 ImGui::SameLine();
1280 const std::string path = util::FileDialogWrapper::ShowSaveFileDialog();
1281 if (!path.empty()) {
1282 auto status =
1284 if (!status.ok()) {
1286 absl::StrFormat("Export failed: %s", status.message());
1288 } else {
1289 message_bundle_status_ = absl::StrFormat("Exported bundle: %s", path);
1291 }
1292 }
1293 }
1294 if (ImGui::IsItemHovered()) {
1295 ImGui::SetTooltip("Export message bundle");
1296 }
1298
1299 ImGui::SetNextItemWidth(
1300 std::max(120.0f, ImGui::GetContentRegionAvail().x - 120.0f));
1301 ImGui::InputTextWithHint("##message_filter", "Filter by text, ID, or address",
1302 &list_filter_);
1303 ImGui::SameLine();
1304 ImGui::SetNextItemWidth(110.0f);
1305 ImGui::Combo("##message_bank", &list_bank_filter_,
1306 "All\0Vanilla\0Expanded\0");
1307
1308 if (!message_bundle_status_.empty()) {
1309 const ImVec4 color = message_bundle_status_error_ ? gui::GetErrorColor()
1311 ImGui::TextWrapped("%s", message_bundle_status_.c_str());
1312 }
1313
1314 const std::vector<MessageRow> rows = CollectMessageRows(true);
1315 const int total = static_cast<int>(CollectMessageRows(false).size());
1316 ImGui::TextDisabled("%d / %d", static_cast<int>(rows.size()), total);
1317
1318 if (list_of_texts_.empty() && expanded_messages_.empty()) {
1320 "Load a ROM to edit dialogue.",
1321 nullptr, nullptr, true});
1322 return;
1323 }
1324 if (rows.empty()) {
1326 empty.icon = ICON_MD_SEARCH;
1327 empty.title = "No matches";
1328 empty.detail = "No messages match this filter.";
1329 empty.action_label = "Clear filter";
1330 empty.compact = true;
1331 empty.on_action = [this]() {
1332 list_filter_.clear();
1334 };
1335 gui::DrawEmptyState(empty);
1336 return;
1337 }
1338
1339 constexpr ImGuiTableFlags kListFlags =
1340 ImGuiTableFlags_Hideable | ImGuiTableFlags_Borders |
1341 ImGuiTableFlags_Resizable | ImGuiTableFlags_RowBg |
1342 ImGuiTableFlags_ScrollY;
1343 if (!BeginTable("##MessagesTable", 4, kListFlags, ImVec2(0, 0))) {
1344 return;
1345 }
1346 TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 64);
1347 TableSetupColumn("Bank", ImGuiTableColumnFlags_WidthFixed, 84);
1348 TableSetupColumn("Contents", ImGuiTableColumnFlags_WidthStretch);
1349 TableSetupColumn("Address", ImGuiTableColumnFlags_WidthFixed, 90);
1350 TableHeadersRow();
1351
1352 const int current_id = CurrentDisplayId();
1353 int target_row = -1;
1354 for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
1355 if (rows[i].display_id == current_id &&
1356 rows[i].expanded == current_message_is_expanded_) {
1357 target_row = i;
1358 break;
1359 }
1360 }
1361
1362 if (scroll_list_to_selection_ && target_row < 0) {
1364 }
1365 ImGuiListClipper clipper;
1366 clipper.Begin(static_cast<int>(rows.size()));
1367 if (scroll_list_to_selection_ && target_row >= 0) {
1368 clipper.IncludeItemByIndex(target_row);
1369 }
1370 while (clipper.Step()) {
1371 for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
1372 const MessageRow& message = rows[row];
1373 const bool selected = row == target_row;
1374 ImGui::TableNextRow();
1375 TableNextColumn();
1376 // Scope every interactive cell, including repeated bank badges, by the
1377 // message identity so filtering does not transfer IDs between rows.
1378 ImGui::PushID(message.expanded ? "expanded" : "vanilla");
1379 ImGui::PushID(message.display_id);
1380 char id_label[16];
1381 std::snprintf(id_label, sizeof(id_label), "$%04X",
1382 message.display_id & 0xFFFF);
1383 if (ImGui::Selectable(id_label, selected,
1384 ImGuiSelectableFlags_SpanAllColumns |
1385 ImGuiSelectableFlags_AllowOverlap)) {
1386 if (!selected && !current_parse_errors_.empty()) {
1387 replace_status_ = "Resolve parse errors before switching messages";
1388 replace_status_error_ = true;
1389 find_panel_open_ = true;
1390 } else if (!selected) {
1392 OpenMessageById(message.display_id);
1393 }
1394 }
1395 gui::ItemContextMenu(nullptr, [this, display_id = message.display_id,
1396 address = message.address]() {
1397 std::vector<gui::MenuItemSpec> items;
1398 auto open_item = gui::MenuItemSpec::Conditional(
1399 "Open Message",
1400 [this, display_id]() {
1401 FinalizePendingUndo();
1402 OpenMessageById(display_id);
1403 },
1404 [this]() { return current_parse_errors_.empty(); });
1405 open_item.icon = ICON_MD_ARROW_FORWARD;
1406 open_item.separator_after = true;
1407 items.push_back(std::move(open_item));
1408 items.push_back(gui::CopyToClipboardItem("Copy Message ID",
1409 util::HexWord(display_id)));
1410 if (display_id >= 0 &&
1411 display_id < static_cast<int>(parsed_messages_.size())) {
1412 items.push_back(gui::CopyToClipboardItem(
1413 "Copy Text", parsed_messages_[display_id]));
1414 }
1415 items.push_back(
1416 gui::CopyToClipboardItem("Copy Address", util::HexLong(address)));
1417 return items;
1418 });
1419 if (selected && scroll_list_to_selection_) {
1420 ImGui::SetScrollHereY(0.25f);
1422 }
1423 TableNextColumn();
1425 message.expanded ? tr("Expanded") : tr("Vanilla"),
1426 message.expanded ? gui::ButtonType::Warning : gui::ButtonType::Info);
1427
1428 TableNextColumn();
1429 const std::string preview =
1430 MessageListPreview(ContentsForDisplayId(message.display_id));
1431 ImGui::TextUnformatted(preview.c_str());
1432
1433 TableNextColumn();
1434 ImGui::TextUnformatted(util::HexLong(message.address).c_str());
1435 ImGui::PopID();
1436 ImGui::PopID();
1437 }
1438 }
1439 EndTable();
1440}
1441
1442void MessageEditor::DrawMessageStructure() {
1443 if (!ImGui::CollapsingHeader(tr("Message Structure"),
1444 ImGuiTreeNodeFlags_DefaultOpen)) {
1445 return;
1446 }
1447 ImGui::Text(tr("Lines: %d"), message_preview_.text_line + 1);
1448
1449 int scroll_count = 0;
1450 int current_line_chars = 0;
1451 int line_num = 0;
1452 const ImVec4 line_color = gui::GetSuccessColor();
1453
1454 for (size_t i = 0; i < current_message_.Data.size(); i++) {
1455 const uint8_t byte = current_message_.Data[i];
1456 if (byte == kScrollVertical) {
1457 scroll_count++;
1458 ImGui::TextColored(gui::GetInfoColor(),
1459 tr(" [V] Scroll at byte %zu (line %d, %d chars)"), i,
1460 line_num, current_line_chars);
1461 current_line_chars = 0;
1462 line_num++;
1463 } else if (byte == kLine1) {
1464 ImGui::TextColored(line_color, tr(" [1] Line 1 at byte %zu"), i);
1465 current_line_chars = 0;
1466 line_num = 0;
1467 } else if (byte == kLine2) {
1468 ImGui::TextColored(line_color, tr(" [2] Line 2 at byte %zu"), i);
1469 current_line_chars = 0;
1470 line_num = 1;
1471 } else if (byte == kLine3) {
1472 ImGui::TextColored(line_color, tr(" [3] Line 3 at byte %zu"), i);
1473 current_line_chars = 0;
1474 line_num = 2;
1475 } else if (byte < 100) {
1476 current_line_chars++;
1477 }
1478 }
1479
1480 if (scroll_count == 0) {
1481 ImGui::TextDisabled(tr("No scroll breaks in this message"));
1482 } else {
1483 ImGui::Text(tr("Total scroll breaks: %d"), scroll_count);
1484 }
1485
1486 ImGui::Separator();
1487 ImGui::TextDisabled(tr("Line width budget (max ~170px):"));
1488 const int estimated_line_width = current_line_chars * 8;
1489 const float width_ratio = static_cast<float>(estimated_line_width) / 170.0f;
1490 const ImVec4 width_color = (width_ratio > 1.0f) ? gui::GetErrorColor()
1491 : (width_ratio > 0.85f) ? gui::GetWarningColor()
1493 ImGui::TextColored(width_color, tr("Last line: ~%dpx / 170px (%d chars)"),
1494 estimated_line_width, current_line_chars);
1495}
1496
1497void MessageEditor::DrawCurrentMessage() {
1498 DrawMessageHeader();
1499 DrawFindBar();
1500
1501 const float available = ImGui::GetContentRegionAvail().y;
1502 const float max_editor = std::max(96.0f, available - 160.0f);
1503 editor_pane_height_ = std::clamp(editor_pane_height_, 96.0f, max_editor);
1504
1505 if (ImGui::BeginChild("##MessageTextPane", ImVec2(0, editor_pane_height_),
1506 true)) {
1507 DrawDialogueTextPane(ImGui::GetContentRegionAvail().y);
1508 }
1509 ImGui::EndChild();
1510
1511 ImGui::InvisibleButton("##message_editor_split", ImVec2(-1.0f, 6.0f));
1512 if (ImGui::IsItemHovered() || ImGui::IsItemActive()) {
1513 ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS);
1514 }
1515 if (ImGui::IsItemActive()) {
1516 editor_pane_height_ += ImGui::GetIO().MouseDelta.y;
1517 }
1518
1519 if (!current_parse_errors_.empty() || !current_parse_warnings_.empty()) {
1520 if (ImGui::BeginChild("##MessageDiagnostics", ImVec2(0, 72), true)) {
1521 if (!current_parse_errors_.empty()) {
1522 ImGui::TextColored(gui::GetErrorColor(), tr("Message parse errors"));
1523 for (const auto& error : current_parse_errors_) {
1524 ImGui::BulletText("%s", error.c_str());
1525 }
1526 }
1527 if (!current_parse_warnings_.empty()) {
1528 ImGui::TextColored(gui::GetWarningColor(),
1529 tr("Message parse warnings"));
1530 for (const auto& warning : current_parse_warnings_) {
1531 ImGui::BulletText("%s", warning.c_str());
1532 }
1533 }
1534 }
1535 ImGui::EndChild();
1536 }
1537 const auto line_warnings = ValidateMessageLineWidths(message_text_box_.text);
1538 if (!line_warnings.empty()) {
1539 ImGui::TextColored(gui::GetWarningColor(), tr("Line width warnings"));
1540 for (const auto& warning : line_warnings) {
1541 ImGui::BulletText("%s", warning.c_str());
1542 }
1543 }
1544
1545 gui::MemoryEditorPopup("Message Data", current_message_.Data);
1546
1547 if (ImGui::BeginChild("##MessagePreview", ImVec2(0, 0), true)) {
1548 EnsureFontTexturesReady();
1549 Text(tr("Message Preview"));
1550 ImGui::SameLine();
1551 if (Button(tr("View Palette"))) {
1552 ImGui::OpenPopup("Palette");
1553 }
1554 if (ImGui::BeginPopup("Palette")) {
1555 gui::DisplayPalette(font_preview_colors_, true);
1556 ImGui::EndPopup();
1557 }
1558 ImGui::SameLine();
1559 ImGui::TextDisabled(tr("Scroll the preview with the mouse wheel"));
1560
1562 BeginChild("CurrentGfxFont", ImVec2(0, 200), true,
1563 ImGuiWindowFlags_NoScrollWithMouse);
1564 current_font_gfx16_canvas_.GetConfig().role =
1566 current_font_gfx16_canvas_.DrawBackground();
1568 current_font_gfx16_canvas_.DrawContextMenu();
1569
1570 if (ImGui::IsWindowHovered()) {
1571 const float wheel = ImGui::GetIO().MouseWheel;
1572 if (wheel > 0 && message_preview_.shown_lines > 0) {
1573 message_preview_.shown_lines--;
1574 } else if (wheel < 0 && message_preview_.shown_lines <
1575 message_preview_.text_line - 2) {
1576 message_preview_.shown_lines++;
1577 }
1578 }
1579
1580 const ImVec2 preview_canvas_size = current_font_gfx16_canvas_.canvas_size();
1581 const float dest_width = std::max(0.0f, preview_canvas_size.x - 8.0f);
1582 const float dest_height = std::max(0.0f, preview_canvas_size.y - 8.0f);
1583 float src_height = 0.0f;
1584 if (dest_width > 0.0f && dest_height > 0.0f) {
1585 const float src_width =
1586 std::min(dest_width * 0.5f, static_cast<float>(kCurrentMessageWidth));
1587 src_height = std::min(dest_height * 0.5f,
1588 static_cast<float>(kCurrentMessageHeight));
1589 current_font_gfx16_canvas_.DrawBitmap(
1590 current_font_gfx16_bitmap_, ImVec2(0, 0),
1591 ImVec2(dest_width, dest_height),
1592 ImVec2(0, message_preview_.shown_lines * 16),
1593 ImVec2(src_width, src_height));
1594 }
1595
1596 {
1597 ImDrawList* overlay_draw_list = ImGui::GetWindowDrawList();
1598 const ImVec2 canvas_p0 = current_font_gfx16_canvas_.zero_point();
1599 const ImVec2 canvas_sz = current_font_gfx16_canvas_.canvas_size();
1600 const float line_height = 16.0f;
1601 float scale_y = 1.0f;
1602 if (dest_height > 0.0f && src_height > 0.0f) {
1603 scale_y = dest_height / src_height;
1604 }
1605 const ImU32 marker_color =
1606 ImGui::ColorConvertFloat4ToU32(gui::GetInfoColor());
1607 for (int marker_line : message_preview_.scroll_marker_lines) {
1608 const float src_y =
1609 (marker_line - message_preview_.shown_lines) * line_height;
1610 const float y = canvas_p0.y + src_y * scale_y;
1611 if (y >= canvas_p0.y && y <= canvas_p0.y + canvas_sz.y) {
1612 overlay_draw_list->AddLine(ImVec2(canvas_p0.x, y),
1613 ImVec2(canvas_p0.x + canvas_sz.x, y),
1614 marker_color, 1.5f);
1615 overlay_draw_list->AddText(
1616 ImVec2(canvas_p0.x + canvas_sz.x + 4, y - 6), marker_color,
1617 "[V]");
1618 }
1619 }
1620 }
1621
1622 current_font_gfx16_canvas_.DrawGrid();
1623 current_font_gfx16_canvas_.DrawOverlay();
1624 EndChild();
1625 DrawMessageStructure();
1626 }
1627 ImGui::EndChild();
1628}
1629
1630void MessageEditor::DrawFontAtlas() {
1631 gui::SectionHeader(ICON_MD_FONT_DOWNLOAD, tr("Font Atlas"),
1633 EnsureFontTexturesReady();
1634 font_gfx_canvas_.GetConfig().role = gui::CanvasRole::kSelectionSource;
1635 gui::BeginCanvas(font_gfx_canvas_, ImVec2(256, 256));
1636 font_gfx_canvas_.DrawBitmap(font_gfx_bitmap_, 0, 0, 2.0f);
1637 font_gfx_canvas_.DrawTileSelector(16, 32);
1638 gui::EndCanvas(font_gfx_canvas_);
1639 ImGui::TextDisabled(
1640 tr("2bpp dialogue font at $070000. Click a tile to inspect it."));
1641}
1642
1643void MessageEditor::DrawExpandedMessageSettings() {
1644 ImGui::Separator();
1645 gui::SectionHeader(ICON_MD_LIBRARY_ADD, tr("Expanded Messages"),
1647
1648 if (HasMessageSourceLayout()) {
1649 ImGui::TextWrapped(
1650 "%s",
1651 tr("Expanded dialogue loads automatically from the project bundle. "
1652 "Reload it after resolving a source error. Inspecting the built ROM "
1653 "uses the "
1654 "manifest data range and does not replace the canonical source."));
1655 if (ImGui::Button(tr("Load canonical source"))) {
1656 const auto status = LoadCanonicalExpandedSource();
1657 if (!status.ok()) {
1658 message_bundle_status_ = std::string(status.message());
1659 message_bundle_status_error_ = true;
1660 }
1661 }
1662 ImGui::SameLine();
1663 }
1664 if (ImGui::Button(HasMessageSourceLayout() ? tr("Inspect built ROM")
1665 : tr("Load from ROM"))) {
1666 auto status = LoadExpandedMessagesFromRom();
1667 if (!status.ok()) {
1668 LOG_WARN("MessageEditor", "Load from ROM: %s",
1669 std::string(status.message()).c_str());
1670 message_bundle_status_ =
1671 absl::StrFormat("Load from ROM failed: %s", status.message());
1672 message_bundle_status_error_ = true;
1673 }
1674 }
1675 ImGui::SameLine();
1676 if (ImGui::Button(tr("Load from File"))) {
1678 if (!path.empty()) {
1679 const auto status = LoadExpandedMessagesFromBin(path);
1680 if (!status.ok()) {
1681 message_bundle_status_ = std::string(status.message());
1682 message_bundle_status_error_ = true;
1683 if (auto* popup_manager = dependencies_.popup_manager) {
1684 popup_manager->Show("Error");
1685 }
1686 }
1687 }
1688 }
1689
1690 if (expanded_messages_.empty()) {
1691 ImGui::TextDisabled(
1692 tr("No expanded bank loaded. Vanilla dialogue is still editable."));
1693 return;
1694 }
1695
1696 ImGui::Text(tr("Source: %s"), expanded_message_path_.c_str());
1697 ImGui::Text(tr("Messages: %lu"), expanded_messages_.size());
1698
1699 const auto inspection_range = ExpandedInspectionRange();
1700 const int capacity = inspection_range.second - inspection_range.first + 1;
1701 const int used = CalculateExpandedBankUsage();
1702 const int remaining = capacity - used;
1703 const float usage_ratio =
1704 capacity > 0 ? static_cast<float>(used) / static_cast<float>(capacity)
1705 : 1.0f;
1706 const ImVec4 capacity_color = (usage_ratio < 0.75f) ? gui::GetSuccessColor()
1707 : (usage_ratio < 0.90f) ? gui::GetWarningColor()
1709 {
1710 gui::StyleColorGuard capacity_style(ImGuiCol_PlotHistogram, capacity_color);
1711 char capacity_label[64];
1712 std::snprintf(capacity_label, sizeof(capacity_label), "%d / %d bytes", used,
1713 capacity);
1714 ImGui::ProgressBar(std::clamp(usage_ratio, 0.0f, 1.0f), ImVec2(-1, 0),
1715 capacity_label);
1716 }
1717 ImGui::TextDisabled(tr("%d bytes free"), remaining);
1718
1719 if (ImGui::Button(tr("Add New Message"))) {
1720 MessageData new_message;
1721 new_message.ID = expanded_messages_.back().ID + 1;
1722 new_message.Address =
1723 expanded_messages_.back().Address +
1724 static_cast<int>(expanded_messages_.back().Data.size());
1725 expanded_messages_.push_back(new_message);
1726 MarkDomainDirty(SaveDomain::kExpandedMessages);
1727 const int display_id = expanded_message_base_id_ + new_message.ID;
1728 if (display_id >= 0 &&
1729 static_cast<size_t>(display_id) >= parsed_messages_.size()) {
1730 parsed_messages_.resize(display_id + 1);
1731 }
1732 if (current_parse_errors_.empty()) {
1733 FinalizePendingUndo();
1734 OpenMessageById(display_id);
1735 }
1736 }
1737 ImGui::SameLine();
1738 if (ImGui::Button(tr("Export to JSON"))) {
1740 if (!path.empty()) {
1741 PRINT_IF_ERROR(ExportMessagesToJson(path, expanded_messages_));
1742 }
1743 }
1744
1745 DrawMessageSourceSync();
1746}
1747
1748void MessageEditor::DrawTokenInserter(const std::vector<TextElement>& elements,
1749 std::string& filter, bool use_parameter) {
1750 ImGui::SetNextItemWidth(-1);
1751 ImGui::InputTextWithHint("##token_filter", "Filter tokens", &filter);
1752 if (!BeginTable("##TokenInserter", 2, kMessageTableFlags, ImVec2(0, 0))) {
1753 return;
1754 }
1755 TableSetupColumn("Token", ImGuiTableColumnFlags_WidthFixed, 110);
1756 TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch);
1757 TableHeadersRow();
1758 for (const auto& text_element : elements) {
1759 if (!filter.empty() &&
1760 !ContainsCaseInsensitive(text_element.GenericToken, filter) &&
1761 !ContainsCaseInsensitive(text_element.Description, filter)) {
1762 continue;
1763 }
1764 ImGui::TableNextRow();
1765 TableNextColumn();
1766 ImGui::PushID(text_element.ID);
1767 const std::string token =
1768 use_parameter ? text_element.GetParamToken(command_parameter_)
1769 : text_element.GenericToken;
1770 if (ImGui::Selectable(text_element.GenericToken.c_str(), false,
1771 ImGuiSelectableFlags_SpanAllColumns)) {
1772 InsertTokenAtCursor(token);
1773 }
1774 if (ImGui::IsItemHovered()) {
1775 ImGui::SetTooltip("Insert %s", token.c_str());
1776 }
1777 ImGui::PopID();
1778 TableNextColumn();
1779 TextWrapped("%s", text_element.Description.c_str());
1780 }
1781 EndTable();
1782}
1783
1784void MessageEditor::DrawTextCommands() {
1785 gui::InputHexByte("Command Parameter", &command_parameter_);
1786 if (ImGui::IsItemHovered()) {
1787 ImGui::SetTooltip("Argument byte for commands such as [W:02] or [SPD:00].");
1788 }
1789 DrawTokenInserter(TextCommands, command_filter_, true);
1790}
1791
1792void MessageEditor::DrawSpecialCharacters() {
1793 DrawTokenInserter(SpecialChars, symbol_filter_, false);
1794}
1795
1796void MessageEditor::DrawDictionary() {
1797 ImGui::SetNextItemWidth(-1);
1798 ImGui::InputTextWithHint("##dictionary_filter", "Filter dictionary",
1799 &dictionary_filter_);
1800 std::vector<int> visible;
1801 visible.reserve(message_preview_.all_dictionaries_.size());
1802 for (int i = 0;
1803 i < static_cast<int>(message_preview_.all_dictionaries_.size()); ++i) {
1804 const auto& dictionary = message_preview_.all_dictionaries_[i];
1805 if (!dictionary_filter_.empty() &&
1806 !ContainsCaseInsensitive(dictionary.Contents, dictionary_filter_) &&
1807 !ContainsCaseInsensitive(dictionary.Token, dictionary_filter_)) {
1808 continue;
1809 }
1810 visible.push_back(i);
1811 }
1812 if (visible.empty()) {
1813 ImGui::TextDisabled(tr("No dictionary entries match."));
1814 return;
1815 }
1816 if (!BeginTable("##Dictionary", 3,
1817 kMessageTableFlags | ImGuiTableFlags_ScrollY, ImVec2(0, 0))) {
1818 return;
1819 }
1820 TableSetupColumn("Token", ImGuiTableColumnFlags_WidthFixed, 72);
1821 TableSetupColumn("Text", ImGuiTableColumnFlags_WidthStretch);
1822 TableSetupColumn("Len", ImGuiTableColumnFlags_WidthFixed, 40);
1823 TableHeadersRow();
1824
1825 ImGuiListClipper clipper;
1826 clipper.Begin(static_cast<int>(visible.size()));
1827 while (clipper.Step()) {
1828 for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
1829 const auto& dictionary = message_preview_.all_dictionaries_[visible[row]];
1830 ImGui::TableNextRow();
1831 TableNextColumn();
1832 ImGui::PushID(dictionary.ID);
1833 if (ImGui::Selectable(dictionary.Token.c_str(), false,
1834 ImGuiSelectableFlags_SpanAllColumns)) {
1835 InsertTokenAtCursor(dictionary.Token);
1836 }
1837 if (ImGui::IsItemHovered()) {
1838 ImGui::SetTooltip("Insert %s", dictionary.Token.c_str());
1839 }
1840 ImGui::PopID();
1841 TableNextColumn();
1842 Text("%s", dictionary.Contents.c_str());
1843 TableNextColumn();
1844 Text("%d", dictionary.Length);
1845 }
1846 }
1847 EndTable();
1848}
1849
1850void MessageEditor::DrawMessageTools() {
1851 if (!ImGui::BeginTabBar("##MessageTools")) {
1852 return;
1853 }
1854 if (ImGui::BeginTabItem(tr("Commands"))) {
1855 DrawTextCommands();
1856 ImGui::EndTabItem();
1857 }
1858 if (ImGui::BeginTabItem(tr("Symbols"))) {
1859 DrawSpecialCharacters();
1860 ImGui::EndTabItem();
1861 }
1862 if (ImGui::BeginTabItem(tr("Dictionary"))) {
1863 DrawDictionary();
1864 ImGui::EndTabItem();
1865 }
1866 ImGui::EndTabBar();
1867}
1868void MessageEditor::UpdateCurrentMessageFromText(const std::string& text) {
1869 PushUndoSnapshot();
1870
1871 MarkDomainDirty(current_message_is_expanded_ ? SaveDomain::kExpandedMessages
1872 : SaveDomain::kVanillaMessages);
1873
1874 auto parse_result = ParseMessageToDataWithDiagnostics(text);
1875 current_parse_errors_ = parse_result.errors;
1876 current_parse_warnings_ = parse_result.warnings;
1877 if (!parse_result.ok()) {
1878 return;
1879 }
1880
1881 std::string raw_text = text;
1882 raw_text.erase(std::remove(raw_text.begin(), raw_text.end(), '\n'),
1883 raw_text.end());
1884
1885 current_message_.RawString = raw_text;
1886 current_message_.ContentsParsed = text;
1887 current_message_.Data = std::move(parse_result.bytes);
1888
1889 int parsed_index = current_message_index_;
1890 if (current_message_is_expanded_) {
1891 parsed_index = expanded_message_base_id_ + current_message_index_;
1892 }
1893
1894 if (parsed_index >= 0) {
1895 if (static_cast<size_t>(parsed_index) >= parsed_messages_.size()) {
1896 parsed_messages_.resize(parsed_index + 1);
1897 }
1898 parsed_messages_[parsed_index] = text;
1899 }
1900
1901 if (current_message_is_expanded_) {
1902 if (current_message_index_ >= 0 &&
1903 current_message_index_ < static_cast<int>(expanded_messages_.size())) {
1904 expanded_messages_[current_message_index_] = current_message_;
1905 }
1906 } else {
1907 if (current_message_index_ >= 0 &&
1908 current_message_index_ < static_cast<int>(list_of_texts_.size())) {
1909 list_of_texts_[current_message_index_] = current_message_;
1910 }
1911 }
1912
1913 DrawMessagePreview();
1914}
1915
1916void MessageEditor::ImportMessageBundleFromFile(const std::string& path) {
1917 message_bundle_status_.clear();
1918 message_bundle_status_error_ = false;
1919
1920 auto entries_or = LoadMessageBundleFromJson(path);
1921 if (!entries_or.ok()) {
1922 message_bundle_status_ =
1923 absl::StrFormat("Import failed: %s", entries_or.status().message());
1924 message_bundle_status_error_ = true;
1925 return;
1926 }
1927
1928 const ExpandedDraftSnapshot snapshot = CaptureExpandedDraft();
1929 const bool rom_dirty_before = rom_ && rom_->dirty();
1930 const bool importing_canonical = IsCanonicalBundlePath(path);
1931 std::string canonical_sha;
1932 if (importing_canonical) {
1933 const auto sha_or = HashFileSha256(std::filesystem::path(path));
1934 if (!sha_or.ok()) {
1935 message_bundle_status_ = std::string(sha_or.status().message());
1936 message_bundle_status_error_ = true;
1937 return;
1938 }
1939 canonical_sha = sha_or.value();
1940 }
1941
1942 int applied = 0;
1943 int errors = 0;
1944 int warnings = 0;
1945 int duplicate_errors = 0;
1946 int parse_error_entries = 0;
1947 int vanilla_updated = 0;
1948 int expanded_updated = 0;
1949 int expanded_created = 0;
1950 bool expanded_modified = false;
1951 std::vector<std::string> issue_samples;
1952
1953 auto add_issue_sample = [&issue_samples](const std::string& issue) {
1954 constexpr size_t kMaxIssueSamples = 4;
1955 if (issue_samples.size() < kMaxIssueSamples) {
1956 issue_samples.push_back(issue);
1957 }
1958 };
1959
1960 auto make_entry_key = [](const MessageBundleEntry& entry) {
1961 return absl::StrFormat("%s:%d", MessageBankToString(entry.bank), entry.id);
1962 };
1963
1964 std::unordered_map<std::string, int> seen_entries;
1965
1966 auto entries = entries_or.value();
1967 for (const auto& entry : entries) {
1968 const std::string entry_key = make_entry_key(entry);
1969 if (seen_entries.find(entry_key) != seen_entries.end()) {
1970 errors++;
1971 duplicate_errors++;
1972 add_issue_sample(absl::StrFormat("Duplicate entry for %s", entry_key));
1973 continue;
1974 }
1975 seen_entries.emplace(entry_key, 1);
1976
1977 auto parse_result = ParseMessageToDataWithDiagnostics(entry.text);
1978 auto line_warnings = ValidateMessageLineWidths(entry.text);
1979 warnings += static_cast<int>(parse_result.warnings.size());
1980 warnings += static_cast<int>(line_warnings.size());
1981
1982 if (!parse_result.ok()) {
1983 errors++;
1984 parse_error_entries++;
1985 if (!parse_result.errors.empty()) {
1986 add_issue_sample(absl::StrFormat("Parse error for %s: %s", entry_key,
1987 parse_result.errors.front()));
1988 } else {
1989 add_issue_sample(absl::StrFormat("Parse error for %s", entry_key));
1990 }
1991 continue;
1992 }
1993
1994 if (entry.bank == MessageBank::kVanilla) {
1995 if (entry.id < 0 || entry.id >= static_cast<int>(list_of_texts_.size())) {
1996 errors++;
1997 add_issue_sample(
1998 absl::StrFormat("Vanilla ID out of range: %d", entry.id));
1999 continue;
2000 }
2001 auto& message = list_of_texts_[entry.id];
2002 message.RawString = entry.text;
2003 message.ContentsParsed = entry.text;
2004 message.Data = parse_result.bytes;
2005 message.DataParsed = parse_result.bytes;
2006 if (entry.id >= 0 &&
2007 entry.id < static_cast<int>(parsed_messages_.size())) {
2008 parsed_messages_[entry.id] = entry.text;
2009 }
2010 vanilla_updated++;
2011 applied++;
2012 } else {
2013 if (entry.id < 0) {
2014 errors++;
2015 add_issue_sample(
2016 absl::StrFormat("Expanded ID out of range: %d", entry.id));
2017 continue;
2018 }
2019 if (entry.id >= static_cast<int>(expanded_messages_.size())) {
2020 const int old_size = static_cast<int>(expanded_messages_.size());
2021 const int target_size = entry.id + 1;
2022 expanded_messages_.resize(target_size);
2023 for (int i = old_size; i < target_size; ++i) {
2024 expanded_messages_[i].ID = i;
2025 }
2026 expanded_created += target_size - old_size;
2027 }
2028 auto& message = expanded_messages_[entry.id];
2029 message.RawString = entry.text;
2030 message.ContentsParsed = entry.text;
2031 message.Data = parse_result.bytes;
2032 message.DataParsed = parse_result.bytes;
2033 const int parsed_index = expanded_message_base_id_ + entry.id;
2034 if (parsed_index >= 0) {
2035 if (static_cast<size_t>(parsed_index) >= parsed_messages_.size()) {
2036 parsed_messages_.resize(parsed_index + 1);
2037 }
2038 parsed_messages_[parsed_index] = entry.text;
2039 }
2040 expanded_modified = true;
2041 expanded_updated++;
2042 applied++;
2043 }
2044 }
2045
2046 if (expanded_modified) {
2047 int pos = ExpandedInspectionRange().first;
2048 for (auto& message : expanded_messages_) {
2049 message.Address = pos;
2050 pos += static_cast<int>(message.Data.size()) + 1;
2051 }
2052 }
2053
2054 if (errors > 0) {
2055 message_bundle_status_ = absl::StrFormat(
2056 "Import finished with %d errors (%d applied: vanilla %d updated, "
2057 "expanded %d updated/%d created; %d warnings, %d duplicates, %d "
2058 "parse failures).",
2059 errors, applied, vanilla_updated, expanded_updated, expanded_created,
2060 warnings, duplicate_errors, parse_error_entries);
2061 if (!issue_samples.empty()) {
2062 message_bundle_status_ = absl::StrFormat(
2063 "%s Example: %s", message_bundle_status_, issue_samples.front());
2064 }
2065 message_bundle_status_error_ = true;
2066 } else {
2067 message_bundle_status_ = absl::StrFormat(
2068 "Imported %d messages (vanilla %d updated, expanded %d updated/%d "
2069 "created, %d warnings).",
2070 applied, vanilla_updated, expanded_updated, expanded_created, warnings);
2071 }
2072 if (errors > 0) {
2073 RestoreExpandedDraft(snapshot);
2074 if (rom_) {
2075 rom_->set_dirty(rom_dirty_before);
2076 }
2077 return;
2078 }
2079 int current_display_id = current_message_index_;
2080 if (current_message_is_expanded_) {
2081 current_display_id = expanded_message_base_id_ + current_message_index_;
2082 }
2083 if (current_display_id >= 0) {
2084 OpenMessageById(current_display_id);
2085 }
2086 if (vanilla_updated > 0) {
2087 MarkDomainDirty(SaveDomain::kVanillaMessages);
2088 }
2089 if (expanded_modified) {
2090 if (importing_canonical) {
2091 source_loaded_canonical_sha_ = canonical_sha;
2092 dirty_state_.expanded_messages = false;
2093 InvalidateSourcePreview();
2094 expanded_message_path_ = path;
2095 } else {
2096 source_loaded_canonical_sha_.clear();
2097 MarkDomainDirty(SaveDomain::kExpandedMessages);
2098 }
2099 }
2100}
2101
2102void MessageEditor::DrawMessagePreview() {
2103 // Render the message to the preview bitmap
2104 message_preview_.DrawMessagePreview(current_message_);
2105
2106 // Validate preview data before updating
2107 if (message_preview_.current_preview_data_.empty()) {
2108 LOG_WARN("MessageEditor", "Preview data is empty, skipping bitmap update");
2109 return;
2110 }
2111
2112 if (current_font_gfx16_bitmap_.is_active()) {
2113 // CRITICAL: Use set_data() to properly update both data_ AND surface_
2114 // mutable_data() returns a reference but doesn't update the surface!
2115 current_font_gfx16_bitmap_.set_data(message_preview_.current_preview_data_);
2116
2117 if (current_font_gfx16_bitmap_.palette().empty() &&
2118 !font_preview_colors_.empty()) {
2119 current_font_gfx16_bitmap_.SetPalette(font_preview_colors_);
2120 }
2121
2122 // Validate surface was updated
2123 if (!current_font_gfx16_bitmap_.surface()) {
2124 LOG_ERROR("MessageEditor", "Bitmap surface is null after set_data()");
2125 return;
2126 }
2127
2128 // Queue texture update (or create if missing) so changes are visible
2129 const auto command = current_font_gfx16_bitmap_.texture()
2132 gfx::Arena::Get().QueueTextureCommand(command, &current_font_gfx16_bitmap_);
2133
2134 LOG_DEBUG(
2135 "MessageEditor",
2136 "Updated message preview bitmap (size: %zu) and queued texture update",
2137 message_preview_.current_preview_data_.size());
2138 } else {
2139 // Create bitmap and queue texture creation with 8-bit indexed depth
2140 current_font_gfx16_bitmap_.Create(kCurrentMessageWidth,
2142 message_preview_.current_preview_data_);
2143 current_font_gfx16_bitmap_.SetPalette(font_preview_colors_);
2145 gfx::Arena::TextureCommandType::CREATE, &current_font_gfx16_bitmap_);
2146
2147 LOG_INFO("MessageEditor",
2148 "Created message preview bitmap (%dx%d) with 8-bit depth and "
2149 "queued texture creation",
2151 }
2152}
2153
2154absl::Status MessageEditor::Save() {
2155 if (!rom_ || !rom_->is_loaded()) {
2156 return absl::FailedPreconditionError("ROM not loaded");
2157 }
2158 if (!current_parse_errors_.empty()) {
2159 return absl::FailedPreconditionError(absl::StrFormat(
2160 "Current message has parse errors: %s", current_parse_errors_.front()));
2161 }
2162
2163 return SaveDirtyDomains(/*include_font_widths=*/true,
2164 /*include_vanilla_messages=*/true,
2165 /*include_expanded_messages=*/true);
2166}
2167
2168absl::StatusOr<MessageEditor::SavePlan> MessageEditor::BuildSavePlan(
2169 bool include_font_widths, bool include_vanilla_messages,
2170 bool include_expanded_messages) const {
2171 if (!rom_ || !rom_->is_loaded()) {
2172 return absl::FailedPreconditionError("ROM not loaded");
2173 }
2174
2175 SavePlan plan;
2176
2177 if (include_font_widths && dirty_state_.font_widths) {
2178 plan.saves_font_widths = true;
2179 plan.writes.push_back(
2180 {SaveDomain::kFontWidths, kCharactersWidth,
2181 std::vector<uint8_t>(message_preview_.width_array.begin(),
2182 message_preview_.width_array.end())});
2183 }
2184
2185 if (include_vanilla_messages && dirty_state_.vanilla_messages) {
2186 std::optional<size_t> expected_message_count;
2187 if (dependencies_.project &&
2188 dependencies_.project->hack_manifest.loaded()) {
2189 const int manifest_count =
2190 dependencies_.project->hack_manifest.message_layout().vanilla_count;
2191 if (manifest_count > 0) {
2192 expected_message_count = static_cast<size_t>(manifest_count);
2193 }
2194 }
2196 const VanillaMessageSavePlan vanilla_plan,
2197 BuildVanillaMessageSavePlan(list_of_texts_, expected_message_count));
2198
2199 plan.saves_vanilla_messages = true;
2200 for (const auto& write : vanilla_plan.writes()) {
2201 plan.writes.push_back(
2202 {SaveDomain::kVanillaMessages, write.start(), write.bytes()});
2203 }
2204 }
2205
2206 if (include_expanded_messages && dirty_state_.expanded_messages &&
2207 !expanded_messages_.empty()) {
2208 const auto range = ExpandedInspectionRange();
2209 const int start = range.first;
2210 const int end = range.second;
2211 if (start < 0 || end < start) {
2212 return absl::InvalidArgumentError("Invalid expanded message region");
2213 }
2214 if (static_cast<uint64_t>(start) >= rom_->size() ||
2215 static_cast<uint64_t>(end) >= rom_->size()) {
2216 return absl::OutOfRangeError(
2217 "Expanded message region is outside the ROM");
2218 }
2219
2220 const int64_t capacity = static_cast<int64_t>(end) - start + 1;
2221 std::vector<uint8_t> bytes;
2222 bytes.reserve(static_cast<size_t>(capacity));
2223 plan.expanded_message_addresses.reserve(expanded_messages_.size());
2224
2225 for (size_t index = 0; index < expanded_messages_.size(); ++index) {
2226 const auto& message = expanded_messages_[index];
2227 auto parsed = ParseMessageToDataWithDiagnostics(message.RawString);
2228 if (!parsed.ok()) {
2229 return absl::InvalidArgumentError(
2230 absl::StrFormat("Expanded message %d is invalid: %s",
2231 static_cast<int>(index), parsed.errors.front()));
2232 }
2233 if (message.RawString.find("[BANK]") != std::string::npos) {
2234 return absl::InvalidArgumentError(absl::StrFormat(
2235 "Expanded message %d contains [BANK], which is only valid in the "
2236 "vanilla message stream",
2237 static_cast<int>(index)));
2238 }
2239
2240 const int64_t needed = static_cast<int64_t>(parsed.bytes.size()) + 1;
2241 if (static_cast<int64_t>(bytes.size()) + needed + 1 > capacity) {
2242 return absl::ResourceExhaustedError(absl::StrFormat(
2243 "Expanded message data exceeds bank boundary "
2244 "(at message %d, used=%d, needed=%d, capacity=%d, end=0x%06X)",
2245 static_cast<int>(index), static_cast<int>(bytes.size()),
2246 static_cast<int>(needed), static_cast<int>(capacity), end));
2247 }
2248
2249 plan.expanded_message_addresses.push_back(start +
2250 static_cast<int>(bytes.size()));
2251 bytes.insert(bytes.end(), parsed.bytes.begin(), parsed.bytes.end());
2252 bytes.push_back(kMessageTerminator);
2253 }
2254 bytes.push_back(0xFF);
2255
2256 plan.saves_expanded_messages = true;
2257 plan.writes.push_back({SaveDomain::kExpandedMessages,
2258 static_cast<uint32_t>(start), std::move(bytes)});
2259 }
2260
2261 for (const auto& write : plan.writes) {
2262 const uint64_t end =
2263 static_cast<uint64_t>(write.start) + write.bytes.size();
2264 if (end > rom_->size() || end > std::numeric_limits<uint32_t>::max()) {
2265 return absl::OutOfRangeError(absl::StrFormat(
2266 "Message save range [0x%06X, 0x%06llX) is outside the ROM",
2267 write.start, static_cast<unsigned long long>(end)));
2268 }
2269 }
2270
2271 return plan;
2272}
2273
2274absl::Status MessageEditor::ValidateSavePlan(const SavePlan& plan) const {
2275 if (!dependencies_.project ||
2276 !dependencies_.project->hack_manifest.loaded() || plan.writes.empty()) {
2277 return absl::OkStatus();
2278 }
2279
2280 std::vector<std::pair<uint32_t, uint32_t>> ranges;
2281 ranges.reserve(plan.writes.size());
2282 for (const auto& write : plan.writes) {
2283 ranges.emplace_back(write.start, write.end());
2284 }
2285
2286 const auto& yaze_project = *dependencies_.project;
2287 if (yaze_project.rom_metadata.write_policy ==
2290 absl::StrContains(yaze_project.hack_manifest.hack_name(),
2291 "Oracle of Secrets")) {
2292 return absl::PermissionDeniedError(kOracleExpandedRomWriteRefusal);
2293 }
2294
2295 const auto status = ValidateHackManifestSaveConflicts(
2296 yaze_project.hack_manifest, yaze_project.rom_metadata.write_policy,
2297 ranges, "message data", "MessageEditor", dependencies_.toast_manager);
2298 return status;
2299}
2300
2301absl::Status MessageEditor::ApplySavePlan(const SavePlan& plan) {
2302 if (plan.writes.empty()) {
2303 return absl::OkStatus();
2304 }
2305
2307 for (const auto& write : plan.writes) {
2308 const char* label = "MessageData";
2309 switch (write.domain) {
2310 case SaveDomain::kFontWidths:
2311 label = "MessageFontWidths";
2312 break;
2313 case SaveDomain::kVanillaMessages:
2314 label = "VanillaMessageBank";
2315 break;
2316 case SaveDomain::kExpandedMessages:
2317 label = "ExpandedMessageBank";
2318 break;
2319 }
2320 RETURN_IF_ERROR(fence.Allow(write.start, write.end(), label));
2321 }
2322
2323 ScopedRomTransaction transaction(*rom_);
2324 yaze::rom::ScopedWriteFence fence_scope(rom_, &fence);
2325 for (const auto& write : plan.writes) {
2327 rom_->WriteVector(static_cast<int>(write.start), write.bytes));
2328 }
2329 transaction.Commit();
2330
2331 if (plan.saves_expanded_messages) {
2332 for (size_t index = 0; index < expanded_messages_.size() &&
2333 index < plan.expanded_message_addresses.size();
2334 ++index) {
2335 expanded_messages_[index].Address =
2336 plan.expanded_message_addresses[index];
2337 }
2338 SyncCurrentExpandedMessageAddress();
2339 }
2340
2341 return absl::OkStatus();
2342}
2343
2344absl::Status MessageEditor::SaveDirtyDomains(bool include_font_widths,
2345 bool include_vanilla_messages,
2346 bool include_expanded_messages) {
2347 ASSIGN_OR_RETURN(const SavePlan plan,
2348 BuildSavePlan(include_font_widths, include_vanilla_messages,
2349 include_expanded_messages));
2350 RETURN_IF_ERROR(ValidateSavePlan(plan));
2351 RETURN_IF_ERROR(ApplySavePlan(plan));
2352 RecordSavedDomains(plan);
2353 return absl::OkStatus();
2354}
2355
2356absl::Status MessageEditor::SaveExpandedMessages() {
2357 return SaveDirtyDomains(/*include_font_widths=*/false,
2358 /*include_vanilla_messages=*/false,
2359 /*include_expanded_messages=*/true);
2360}
2361
2362absl::Status MessageEditor::BeginSaveTransaction() {
2363 if (save_transaction_active_) {
2364 return absl::FailedPreconditionError(
2365 "Message save transaction is already active");
2366 }
2367 transaction_dirty_snapshot_ = dirty_state_;
2368 transaction_saved_domains_ = {};
2369 transaction_expanded_address_snapshot_.clear();
2370 transaction_expanded_address_snapshot_.reserve(expanded_messages_.size());
2371 for (const auto& message : expanded_messages_) {
2372 transaction_expanded_address_snapshot_.push_back(message.Address);
2373 }
2374 save_transaction_active_ = true;
2375 return absl::OkStatus();
2376}
2377
2378void MessageEditor::RollbackSaveTransaction() {
2379 if (!save_transaction_active_) {
2380 return;
2381 }
2382 dirty_state_ = transaction_dirty_snapshot_;
2383 for (size_t index = 0; index < expanded_messages_.size() &&
2384 index < transaction_expanded_address_snapshot_.size();
2385 ++index) {
2386 expanded_messages_[index].Address =
2387 transaction_expanded_address_snapshot_[index];
2388 }
2389 SyncCurrentExpandedMessageAddress();
2390 transaction_dirty_snapshot_ = {};
2391 transaction_saved_domains_ = {};
2392 transaction_expanded_address_snapshot_.clear();
2393 save_transaction_active_ = false;
2394}
2395
2396void MessageEditor::CommitSaveTransaction() {
2397 if (!save_transaction_active_) {
2398 return;
2399 }
2400 ClearSavedDomains(transaction_saved_domains_);
2401 transaction_dirty_snapshot_ = {};
2402 transaction_saved_domains_ = {};
2403 transaction_expanded_address_snapshot_.clear();
2404 save_transaction_active_ = false;
2405}
2406
2407void MessageEditor::MarkDomainDirty(SaveDomain domain) {
2408 bool* dirty = nullptr;
2409 bool* transaction_saved = nullptr;
2410 switch (domain) {
2411 case SaveDomain::kFontWidths:
2412 dirty = &dirty_state_.font_widths;
2413 transaction_saved = &transaction_saved_domains_.font_widths;
2414 break;
2415 case SaveDomain::kVanillaMessages:
2416 dirty = &dirty_state_.vanilla_messages;
2417 transaction_saved = &transaction_saved_domains_.vanilla_messages;
2418 break;
2419 case SaveDomain::kExpandedMessages:
2420 dirty = &dirty_state_.expanded_messages;
2421 transaction_saved = &transaction_saved_domains_.expanded_messages;
2422 break;
2423 }
2424 *dirty = true;
2425 if (save_transaction_active_) {
2426 *transaction_saved = false;
2427 }
2428 if (domain == SaveDomain::kExpandedMessages) {
2429 InvalidateSourcePreview();
2430 }
2431 if (rom_ && !(domain == SaveDomain::kExpandedMessages &&
2432 ExpandedEditsAreSourceOwned())) {
2433 rom_->set_dirty(true);
2434 }
2435}
2436
2437void MessageEditor::RecordSavedDomains(const SavePlan& plan) {
2440 if (!save_transaction_active_) {
2441 ClearSavedDomains(saved);
2442 return;
2443 }
2444 transaction_saved_domains_.font_widths |= saved.font_widths;
2445 transaction_saved_domains_.vanilla_messages |= saved.vanilla_messages;
2446 transaction_saved_domains_.expanded_messages |= saved.expanded_messages;
2447}
2448
2449void MessageEditor::ClearSavedDomains(const DirtyState& saved) {
2450 if (saved.font_widths) {
2451 dirty_state_.font_widths = false;
2452 }
2453 if (saved.vanilla_messages) {
2454 dirty_state_.vanilla_messages = false;
2455 }
2456 if (saved.expanded_messages) {
2457 dirty_state_.expanded_messages = false;
2458 }
2459}
2460
2461void MessageEditor::SyncCurrentExpandedMessageAddress() {
2462 if (!current_message_is_expanded_ || current_message_index_ < 0 ||
2463 current_message_index_ >= static_cast<int>(expanded_messages_.size())) {
2464 return;
2465 }
2466 current_message_.Address = expanded_messages_[current_message_index_].Address;
2467}
2468
2469absl::Status MessageEditor::LoadCanonicalExpandedSource() {
2470 if (!HasMessageSourceLayout()) {
2471 return absl::FailedPreconditionError(
2472 "The project hack manifest does not define messages.source");
2473 }
2474 if (dirty_state_.expanded_messages || dirty_state_.vanilla_messages ||
2475 dirty_state_.font_widths || pending_undo_before_.has_value() ||
2476 !current_parse_errors_.empty()) {
2477 return absl::FailedPreconditionError(
2478 "The Message editor has pending edits; canonical source was not "
2479 "loaded");
2480 }
2481 auto source_or = LoadMessageSource(*dependencies_.project);
2482 if (!source_or.ok()) {
2483 return source_or.status();
2484 }
2485 // Commit only after the complete source bank validates. This does not import
2486 // vanilla messages, set ROM dirty, write a preview, or publish source files.
2487 expanded_message_base_id_ = source_or->first_expanded_id;
2488 expanded_messages_ = std::move(source_or->messages);
2489 expanded_message_path_ = source_or->canonical_bundle_path.string();
2490 source_loaded_canonical_sha_ = std::move(source_or->source_sha256);
2491 parsed_messages_.resize(expanded_message_base_id_ +
2492 expanded_messages_.size());
2493 for (const auto& message : expanded_messages_) {
2494 parsed_messages_[expanded_message_base_id_ + message.ID] =
2495 message.RawString;
2496 }
2497 InvalidateSourcePreview();
2498 NoteLoadedMessages();
2499 message_bundle_status_ =
2500 absl::StrFormat("Loaded %zu expanded messages from project source: %s",
2501 expanded_messages_.size(), expanded_message_path_);
2502 message_bundle_status_error_ = false;
2503 if (current_message_is_expanded_) {
2504 OpenMessageById(expanded_message_base_id_ + current_message_index_);
2505 }
2506 return absl::OkStatus();
2507}
2508
2509absl::Status MessageEditor::LoadExpandedMessagesFromBin(
2510 const std::string& path) {
2511 const ExpandedDraftSnapshot snapshot = CaptureExpandedDraft();
2512 const bool rom_dirty_before = rom_ && rom_->dirty();
2513 expanded_message_base_id_ = ResolveExpandedMessageBaseId();
2514 parsed_messages_.resize(
2515 static_cast<size_t>(std::max(expanded_message_base_id_, 0)));
2516 expanded_messages_.clear();
2517 std::vector<std::string> parsed_expanded;
2518 std::string mutable_path = path;
2519 const auto status =
2520 LoadExpandedMessages(mutable_path, parsed_expanded, expanded_messages_,
2521 message_preview_.all_dictionaries_);
2522 if (!status.ok() || expanded_messages_.empty()) {
2523 RestoreExpandedDraft(snapshot);
2524 if (rom_) {
2525 rom_->set_dirty(rom_dirty_before);
2526 }
2527 if (!status.ok()) {
2528 return status;
2529 }
2530 return absl::NotFoundError("No expanded messages found in file");
2531 }
2532 parsed_messages_.insert(parsed_messages_.end(), parsed_expanded.begin(),
2533 parsed_expanded.end());
2534 expanded_message_path_ = path;
2535 source_loaded_canonical_sha_.clear();
2536 NoteLoadedMessages();
2537 MarkDomainDirty(SaveDomain::kExpandedMessages);
2538 return absl::OkStatus();
2539}
2540
2541absl::Status MessageEditor::LoadExpandedMessagesFromRom() {
2542 if (!rom_ || !rom_->is_loaded()) {
2543 return absl::FailedPreconditionError("ROM not loaded");
2544 }
2545
2546 const ExpandedDraftSnapshot snapshot = CaptureExpandedDraft();
2547 const bool rom_dirty_before = rom_ && rom_->dirty();
2548 const auto range = ExpandedInspectionRange();
2549 const int end = std::min(range.second, static_cast<int>(rom_->size()) - 1);
2550 auto loaded = ReadExpandedTextData(rom_->mutable_data(), range.first, end);
2551 if (loaded.empty()) {
2552 RestoreExpandedDraft(snapshot);
2553 if (rom_) {
2554 rom_->set_dirty(rom_dirty_before);
2555 }
2556 return absl::NotFoundError(
2557 "No expanded messages found in ROM at the manifest data range");
2558 }
2559
2560 expanded_message_base_id_ = ResolveExpandedMessageBaseId();
2561 parsed_messages_.resize(
2562 static_cast<size_t>(std::max(expanded_message_base_id_, 0)));
2563 expanded_messages_ = std::move(loaded);
2564 auto parsed_expanded =
2565 ParseMessageData(expanded_messages_, message_preview_.all_dictionaries_);
2566 for (const auto& msg : expanded_messages_) {
2567 if (msg.ID >= 0 && msg.ID < static_cast<int>(parsed_expanded.size())) {
2568 parsed_messages_.push_back(parsed_expanded[msg.ID]);
2569 }
2570 }
2571
2572 expanded_message_path_ = "(ROM inspection)";
2573 source_loaded_canonical_sha_.clear();
2574 dirty_state_.expanded_messages = false;
2575 InvalidateSourcePreview();
2576 NoteLoadedMessages();
2577 return absl::OkStatus();
2578}
2579
2580int MessageEditor::CalculateExpandedBankUsage() const {
2581 if (expanded_messages_.empty())
2582 return 0;
2583 int total = 0;
2584 for (const auto& msg : expanded_messages_) {
2585 total += static_cast<int>(msg.Data.size()) + 1; // +1 for 0x7F
2586 }
2587 total += 1; // +1 for final 0xFF
2588 return total;
2589}
2590
2591absl::Status MessageEditor::Cut() {
2592 // Ensure that text is currently selected in the text box.
2593 if (!message_text_box_.text.empty()) {
2594 // Cut the selected text in the control and paste it into the Clipboard.
2595 message_text_box_.Cut();
2596 }
2597 return absl::OkStatus();
2598}
2599
2600absl::Status MessageEditor::Paste() {
2601 // Determine if there is any text in the Clipboard to paste into the
2602 if (ImGui::GetClipboardText() != nullptr) {
2603 // Paste the text from the Clipboard into the text box.
2604 message_text_box_.Paste();
2605 }
2606 return absl::OkStatus();
2607}
2608
2609absl::Status MessageEditor::Copy() {
2610 // Ensure that text is selected in the text box.
2611 if (message_text_box_.selection_length > 0) {
2612 // Copy the selected text to the Clipboard.
2613 message_text_box_.Copy();
2614 }
2615 return absl::OkStatus();
2616}
2617
2618void MessageEditor::PushUndoSnapshot() {
2619 if (pending_undo_before_.has_value()) {
2620 // If we're still editing the same message, keep the existing "before"
2621 // snapshot so the entire edit session becomes a single undo step.
2622 if (pending_undo_before_->message_index == current_message_index_ &&
2623 pending_undo_before_->is_expanded == current_message_is_expanded_) {
2624 return;
2625 }
2626 FinalizePendingUndo();
2627 }
2628
2629 // Capture current state as "before"
2630 int parsed_index = current_message_index_;
2631 if (current_message_is_expanded_) {
2632 parsed_index = expanded_message_base_id_ + current_message_index_;
2633 }
2634 std::string text;
2635 if (parsed_index >= 0 &&
2636 parsed_index < static_cast<int>(parsed_messages_.size())) {
2637 text = parsed_messages_[parsed_index];
2638 }
2639 pending_undo_before_ =
2640 MessageSnapshot{current_message_, text, current_message_index_,
2641 current_message_is_expanded_};
2642}
2643
2644void MessageEditor::FinalizePendingUndo() {
2645 if (!pending_undo_before_.has_value())
2646 return;
2647
2648 // The "after" snapshot must correspond to the same message as the pending
2649 // "before", even if the user navigated to a different message in the UI.
2650 const int message_index = pending_undo_before_->message_index;
2651 const bool is_expanded = pending_undo_before_->is_expanded;
2652
2653 MessageData after_message;
2654 if (is_expanded) {
2655 if (message_index < 0 ||
2656 message_index >= static_cast<int>(expanded_messages_.size())) {
2657 pending_undo_before_.reset();
2658 return;
2659 }
2660 after_message = expanded_messages_[message_index];
2661 } else {
2662 if (message_index < 0 ||
2663 message_index >= static_cast<int>(list_of_texts_.size())) {
2664 pending_undo_before_.reset();
2665 return;
2666 }
2667 after_message = list_of_texts_[message_index];
2668 }
2669
2670 int parsed_index = message_index;
2671 if (is_expanded) {
2672 parsed_index = expanded_message_base_id_ + message_index;
2673 }
2674 std::string text;
2675 if (parsed_index >= 0 &&
2676 parsed_index < static_cast<int>(parsed_messages_.size())) {
2677 text = parsed_messages_[parsed_index];
2678 }
2679 MessageSnapshot after{std::move(after_message), std::move(text),
2680 message_index, is_expanded};
2681
2682 undo_manager_.Push(std::make_unique<MessageEditAction>(
2683 std::move(*pending_undo_before_), std::move(after),
2684 [this](const MessageSnapshot& s) { ApplySnapshot(s); }));
2685 pending_undo_before_.reset();
2686}
2687
2688void MessageEditor::ApplySnapshot(const MessageSnapshot& snapshot) {
2689 current_message_ = snapshot.message;
2690 current_message_index_ = snapshot.message_index;
2691 current_message_is_expanded_ = snapshot.is_expanded;
2692 message_text_box_.text = snapshot.parsed_text;
2693 const auto diagnostics =
2695 current_parse_errors_ = diagnostics.errors;
2696 current_parse_warnings_ = diagnostics.warnings;
2697
2698 int parsed_index = snapshot.message_index;
2699 if (snapshot.is_expanded) {
2700 parsed_index = expanded_message_base_id_ + snapshot.message_index;
2701 }
2702 if (parsed_index >= 0 &&
2703 parsed_index < static_cast<int>(parsed_messages_.size())) {
2704 parsed_messages_[parsed_index] = snapshot.parsed_text;
2705 }
2706
2707 if (snapshot.is_expanded) {
2708 if (snapshot.message_index >= 0 &&
2709 snapshot.message_index < static_cast<int>(expanded_messages_.size())) {
2710 expanded_messages_[snapshot.message_index] = snapshot.message;
2711 }
2712 } else {
2713 if (snapshot.message_index >= 0 &&
2714 snapshot.message_index < static_cast<int>(list_of_texts_.size())) {
2715 list_of_texts_[snapshot.message_index] = snapshot.message;
2716 }
2717 }
2718
2719 MarkDomainDirty(snapshot.is_expanded ? SaveDomain::kExpandedMessages
2720 : SaveDomain::kVanillaMessages);
2721 DrawMessagePreview();
2722}
2723
2724absl::Status MessageEditor::Undo() {
2725 FinalizePendingUndo();
2726 return undo_manager_.Undo();
2727}
2728
2729absl::Status MessageEditor::Redo() {
2730 return undo_manager_.Redo();
2731}
2732
2733void MessageEditor::Delete() {
2734 // Determine if any text is selected in the TextBox control.
2735 if (message_text_box_.selection_length == 0) {
2736 // clear all of the text in the textbox.
2737 message_text_box_.clear();
2738 }
2739}
2740
2741void MessageEditor::SelectAll() {
2742 // Determine if any text is selected in the TextBox control.
2743 if (message_text_box_.selection_length == 0) {
2744 // Select all text in the text box.
2745 message_text_box_.SelectAll();
2746
2747 // Move the cursor to the text box.
2748 message_text_box_.Focus();
2749 }
2750}
2751
2752absl::Status MessageEditor::Find() {
2753 // Edit > Find is a one-shot menu action. The bar itself is drawn every
2754 // frame from the message editor panel so it stays open across frames.
2755 find_panel_open_ = true;
2756 find_focus_search_ = true;
2757 if (dependencies_.window_manager) {
2758 dependencies_.window_manager->OpenWindow(dependencies_.session_id,
2759 "message.message_editor");
2760 }
2761 return absl::OkStatus();
2762}
2763
2764int MessageEditor::ReplaceCurrentMatch() {
2765 if (search_text_.empty()) {
2766 return 0;
2767 }
2768
2769 std::string& text = message_text_box_.text;
2770 const size_t cursor = static_cast<size_t>(
2771 std::clamp(editor_cursor_, 0, static_cast<int>(text.size())));
2772 const size_t from = find_has_match_ ? find_match_start_ : cursor;
2773 auto match = FindNextTextMatch(text, search_text_, from, case_sensitive_,
2774 match_whole_word_);
2775 if (!match && from > 0) {
2776 match = FindNextTextMatch(text, search_text_, 0, case_sensitive_,
2777 match_whole_word_);
2778 }
2779 if (!match) {
2780 return 0;
2781 }
2782
2783 text.replace(*match, search_text_.size(), replace_text_);
2784 pending_selection_start_ = static_cast<int>(*match);
2785 pending_selection_end_ =
2786 pending_selection_start_ + static_cast<int>(replace_text_.size());
2787 editor_cursor_ = pending_selection_end_;
2788 find_has_match_ = false;
2789 ImGui::ClearActiveID();
2790 UpdateCurrentMessageFromText(text);
2791 FinalizePendingUndo();
2792 return 1;
2793}
2794
2795int MessageEditor::ReplaceAllMatches() {
2796 if (search_text_.empty()) {
2797 return 0;
2798 }
2799 if (!current_parse_errors_.empty()) {
2800 replace_status_ =
2801 "Replace All blocked: resolve the current message parse errors first";
2802 replace_status_error_ = true;
2803 return -1;
2804 }
2805
2806 auto replace_in_text = [&](std::string& text) -> int {
2807 int count = 0;
2808 std::string search = search_text_;
2809
2810 if (!case_sensitive_) {
2811 std::transform(search.begin(), search.end(), search.begin(), ::tolower);
2812 }
2813
2814 size_t pos = 0;
2815 while (pos < text.size()) {
2816 std::string source = text;
2817 if (!case_sensitive_) {
2818 std::transform(source.begin(), source.end(), source.begin(), ::tolower);
2819 }
2820
2821 size_t found = source.find(search, pos);
2822 if (found == std::string::npos)
2823 break;
2824
2825 if (match_whole_word_) {
2826 bool start_ok = (found == 0 || !std::isalnum(source[found - 1]));
2827 bool end_ok = (found + search.size() >= source.size() ||
2828 !std::isalnum(source[found + search.size()]));
2829 if (!start_ok || !end_ok) {
2830 pos = found + 1;
2831 continue;
2832 }
2833 }
2834
2835 text.replace(found, search_text_.size(), replace_text_);
2836 pos = found + replace_text_.size();
2837 count++;
2838 }
2839 return count;
2840 };
2841
2842 struct PlannedReplacement {
2843 int message_index;
2844 bool is_expanded;
2845 std::string text;
2846 int count;
2847 };
2848 std::vector<PlannedReplacement> plan;
2849
2850 const auto plan_replacements = [&](const std::vector<MessageData>& messages,
2851 bool is_expanded) -> bool {
2852 for (size_t i = 0; i < messages.size(); ++i) {
2853 const int parsed_index =
2854 (is_expanded ? expanded_message_base_id_ : 0) + static_cast<int>(i);
2855 if (parsed_index < 0 ||
2856 parsed_index >= static_cast<int>(parsed_messages_.size())) {
2857 continue;
2858 }
2859
2860 std::string text = parsed_messages_[parsed_index];
2861 const int count = replace_in_text(text);
2862 if (count == 0) {
2863 continue;
2864 }
2865
2866 const auto parsed = ParseMessageToDataWithDiagnostics(text);
2867 if (!parsed.ok() ||
2868 (is_expanded && text.find("[BANK]") != std::string::npos)) {
2869 const std::string error =
2870 !parsed.ok() ? parsed.errors.front()
2871 : "[BANK] is not valid in expanded messages";
2872 replace_status_ = absl::StrFormat(
2873 "Replace All aborted at %s message %d: %s",
2874 is_expanded ? "expanded" : "vanilla", static_cast<int>(i), error);
2875 replace_status_error_ = true;
2876 return false;
2877 }
2878
2879 plan.push_back(
2880 {static_cast<int>(i), is_expanded, std::move(text), count});
2881 }
2882 return true;
2883 };
2884
2885 // Validate the complete batch before changing any message or undo state.
2886 if (!plan_replacements(list_of_texts_, false) ||
2887 !plan_replacements(expanded_messages_, true)) {
2888 return -1;
2889 }
2890
2891 const int previous_index = current_message_index_;
2892 const bool previous_expanded = current_message_is_expanded_;
2893 int total_replacements = 0;
2894
2895 for (const auto& replacement : plan) {
2896 current_message_ = replacement.is_expanded
2897 ? expanded_messages_[replacement.message_index]
2898 : list_of_texts_[replacement.message_index];
2899 current_message_index_ = replacement.message_index;
2900 current_message_is_expanded_ = replacement.is_expanded;
2901 message_text_box_.text = replacement.text;
2902 UpdateCurrentMessageFromText(replacement.text);
2903 FinalizePendingUndo();
2904 total_replacements += replacement.count;
2905 }
2906
2907 current_message_index_ = previous_index;
2908 current_message_is_expanded_ = previous_expanded;
2909
2910 // Refresh the current message's text box from updated data
2911 int current_parsed_idx = current_message_index_;
2912 if (current_message_is_expanded_) {
2913 current_parsed_idx = expanded_message_base_id_ + current_message_index_;
2914 }
2915 if (current_parsed_idx >= 0 &&
2916 current_parsed_idx < static_cast<int>(parsed_messages_.size())) {
2917 message_text_box_.text = parsed_messages_[current_parsed_idx];
2918 }
2919
2920 // Refresh current_message_ to reflect replacements
2921 if (current_message_is_expanded_) {
2922 if (current_message_index_ >= 0 &&
2923 current_message_index_ < static_cast<int>(expanded_messages_.size())) {
2924 current_message_ = expanded_messages_[current_message_index_];
2925 }
2926 } else {
2927 if (current_message_index_ >= 0 &&
2928 current_message_index_ < static_cast<int>(list_of_texts_.size())) {
2929 current_message_ = list_of_texts_[current_message_index_];
2930 }
2931 }
2932
2933 const auto current_diagnostics =
2934 ParseMessageToDataWithDiagnostics(message_text_box_.text);
2935 current_parse_errors_ = current_diagnostics.errors;
2936 current_parse_warnings_ = current_diagnostics.warnings;
2937
2938 return total_replacements;
2939}
2940
2941} // namespace editor
2942} // namespace yaze
auto data() const
Definition rom.h:201
auto size() const
Definition rom.h:200
bool is_loaded() const
Definition rom.h:187
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:259
project::YazeProject * project() const
Definition editor.h:358
zelda3::GameData * game_data() const
Definition editor.h:357
EditorDependencies dependencies_
Definition editor.h:370
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
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:27
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)
void BeginPadding(int i)
Definition style.cc:296
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:826
void SectionHeader(const char *icon, const char *label, const ImVec4 &color)
void EndPadding()
Definition style.cc:300
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:510
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:58
core::HackManifest hack_manifest
Definition project.h:360