yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
story_event_graph_panel.h
Go to the documentation of this file.
1#ifndef YAZE_APP_EDITOR_ORACLE_PANELS_STORY_EVENT_GRAPH_PANEL_H
2#define YAZE_APP_EDITOR_ORACLE_PANELS_STORY_EVENT_GRAPH_PANEL_H
3
4#include <atomic>
5#include <cmath>
6#include <cstdint>
7#include <filesystem>
8#include <memory>
9#include <optional>
10#include <string>
11#include <unordered_map>
12#include <vector>
13#include "util/i18n/tr.h"
14
21#include "app/gui/core/icons.h"
22#include "core/hack_manifest.h"
24#include "core/project.h"
27#include "imgui/imgui.h"
28#include "imgui/misc/cpp/imgui_stdlib.h"
29#include "util/file_util.h"
30
31namespace yaze::editor {
32
44class StoryEventGraphPanel : public WindowContent {
45 public:
48
52 void SetManifest(core::HackManifest* manifest) { manifest_ = manifest; }
53
54 std::string GetId() const override { return "oracle.story_event_graph"; }
55 std::string GetDisplayName() const override { return "Story Event Graph"; }
56 std::string GetIcon() const override { return ICON_MD_ACCOUNT_TREE; }
57 std::string GetEditorCategory() const override { return "Agent"; }
61 std::string GetWorkflowGroup() const override { return "Planning"; }
62 std::string GetWorkflowDescription() const override {
63 return "Inspect narrative and progression dependencies for the active "
64 "project";
65 }
66 bool IsEnabled() const override {
68 auto* backend = GetPlanningBackend();
69 return project != nullptr && project->project_opened() &&
70 backend != nullptr;
71 }
72 std::string GetDisabledTooltip() const override {
73 return "Story graph data is not available for the active hack project";
74 }
78 float GetPreferredWidth() const override { return 600.0f; }
79
80 void Draw(bool* /*p_open*/) override {
81 if (!IsEnabled()) {
82 ImGui::TextDisabled("%s", GetDisabledTooltip().c_str());
83 return;
84 }
85
86 // Lazily resolve the manifest from the project context
87 if (!manifest_) {
89 if (auto* backend = GetWorkflowBackend()) {
90 manifest_ = backend->ResolveManifest(project);
91 } else if (project && project->hack_manifest.loaded()) {
92 manifest_ = &project->hack_manifest;
93 }
94 }
95
97 ImGui::TextDisabled(tr("No hack project loaded"));
98 ImGui::TextDisabled(
99 tr("Open a project with a hack manifest to view story events."));
100 return;
101 }
102
106
107 const auto* graph = GetStoryGraph();
108 if (graph == nullptr || !graph->loaded()) {
109 ImGui::TextDisabled(tr("No story events data available"));
110 return;
111 }
112
113 // Controls row
114 if (ImGui::Button(tr("Reset View"))) {
115 scroll_x_ = 0;
116 scroll_y_ = 0;
117 zoom_ = 1.0f;
118 }
119 ImGui::SameLine();
120 ImGui::SliderFloat(tr("Zoom"), &zoom_, 0.3f, 2.0f, "%.1f");
121 ImGui::SameLine();
122 ImGui::Text(tr("Nodes: %zu Edges: %zu"), graph->nodes().size(),
123 graph->edges().size());
124 ImGui::SameLine();
125 const auto prog_opt =
129 if (prog_opt.has_value()) {
130 ImGui::TextDisabled(tr("Crystals: %d State: %s"),
131 prog_opt->GetCrystalCount(),
132 prog_opt->GetGameStateName().c_str());
133 } else {
134 ImGui::TextDisabled(tr("No SRAM loaded"));
135 }
136
137 ImGui::SameLine();
138 if (ImGui::SmallButton(tr("Import .srm..."))) {
140 }
141 ImGui::SameLine();
142 if (ImGui::SmallButton(tr("Clear SRAM"))) {
144 }
145 ImGui::SameLine();
147 if (!loaded_srm_path_.empty()) {
148 const std::filesystem::path p(loaded_srm_path_);
149 ImGui::SameLine();
150 ImGui::TextDisabled(tr("SRM: %s"), p.filename().string().c_str());
151 if (ImGui::IsItemHovered()) {
152 ImGui::SetTooltip("%s", loaded_srm_path_.c_str());
153 }
154 }
155
156 if (!last_srm_error_.empty()) {
157 ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), tr("SRM error: %s"),
158 last_srm_error_.c_str());
159 }
160
161 ImGui::Separator();
162
163 DrawFilterControls(*graph);
164 UpdateFilterCache(*graph);
165
166 ImGui::Separator();
167
168 // Main canvas area
169 ImVec2 canvas_pos = ImGui::GetCursorScreenPos();
170 ImVec2 canvas_size = ImGui::GetContentRegionAvail();
171
172 // Reserve space for detail sidebar if a node is selected
173 float sidebar_width = selected_node_.empty() ? 0.0f : 250.0f;
174 canvas_size.x -= sidebar_width;
175
176 if (canvas_size.x < 100 || canvas_size.y < 100)
177 return;
178
179 ImGui::InvisibleButton(
180 "story_canvas", canvas_size,
181 ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight);
182
183 bool is_hovered = ImGui::IsItemHovered();
184 bool is_active = ImGui::IsItemActive();
185
186 // Pan with right mouse button
187 if (is_active && ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
188 ImVec2 delta = ImGui::GetIO().MouseDelta;
189 scroll_x_ += delta.x;
190 scroll_y_ += delta.y;
191 }
192
193 // Zoom with scroll wheel
194 if (is_hovered) {
195 float wheel = ImGui::GetIO().MouseWheel;
196 if (wheel != 0.0f) {
197 zoom_ *= (wheel > 0) ? 1.1f : 0.9f;
198 if (zoom_ < 0.3f)
199 zoom_ = 0.3f;
200 if (zoom_ > 2.0f)
201 zoom_ = 2.0f;
202 }
203 }
204
205 ImDrawList* draw_list = ImGui::GetWindowDrawList();
206
207 // Clip to canvas
208 draw_list->PushClipRect(
209 canvas_pos,
210 ImVec2(canvas_pos.x + canvas_size.x, canvas_pos.y + canvas_size.y),
211 true);
212
213 // Center offset
214 float cx = canvas_pos.x + canvas_size.x * 0.5f + scroll_x_;
215 float cy = canvas_pos.y + canvas_size.y * 0.5f + scroll_y_;
216
217 // Draw edges first (behind nodes)
218 for (const auto& edge : graph->edges()) {
219 const auto* from_node = graph->GetNode(edge.from);
220 const auto* to_node = graph->GetNode(edge.to);
221 if (!from_node || !to_node)
222 continue;
223 if (hide_non_matching_) {
224 if (!IsNodeVisible(edge.from) || !IsNodeVisible(edge.to))
225 continue;
226 }
227
228 ImVec2 p1(cx + from_node->pos_x * zoom_ + kNodeWidth * zoom_ * 0.5f,
229 cy + from_node->pos_y * zoom_);
230 ImVec2 p2(cx + to_node->pos_x * zoom_ - kNodeWidth * zoom_ * 0.5f,
231 cy + to_node->pos_y * zoom_);
232
233 // Bezier control points
234 float ctrl_dx = (p2.x - p1.x) * 0.4f;
235 ImVec2 cp1(p1.x + ctrl_dx, p1.y);
236 ImVec2 cp2(p2.x - ctrl_dx, p2.y);
237
238 draw_list->AddBezierCubic(p1, cp1, cp2, p2, IM_COL32(150, 150, 150, 180),
239 1.5f * zoom_);
240
241 // Arrow head
242 ImVec2 dir(p2.x - cp2.x, p2.y - cp2.y);
243 float len = sqrtf(dir.x * dir.x + dir.y * dir.y);
244 if (len > 0) {
245 dir.x /= len;
246 dir.y /= len;
247 float arrow_size = 8.0f * zoom_;
248 ImVec2 arrow1(p2.x - dir.x * arrow_size + dir.y * arrow_size * 0.4f,
249 p2.y - dir.y * arrow_size - dir.x * arrow_size * 0.4f);
250 ImVec2 arrow2(p2.x - dir.x * arrow_size - dir.y * arrow_size * 0.4f,
251 p2.y - dir.y * arrow_size + dir.x * arrow_size * 0.4f);
252 draw_list->AddTriangleFilled(p2, arrow1, arrow2,
253 IM_COL32(150, 150, 150, 200));
254 }
255 }
256
257 // Draw nodes
258 ImVec2 mouse_pos = ImGui::GetIO().MousePos;
259
260 for (const auto& node : graph->nodes()) {
261 if (hide_non_matching_ && !IsNodeVisible(node.id)) {
262 continue;
263 }
264
265 float nx = cx + node.pos_x * zoom_ - kNodeWidth * zoom_ * 0.5f;
266 float ny = cy + node.pos_y * zoom_ - kNodeHeight * zoom_ * 0.5f;
267 float nw = kNodeWidth * zoom_;
268 float nh = kNodeHeight * zoom_;
269
270 ImVec2 node_min(nx, ny);
271 ImVec2 node_max(nx + nw, ny + nh);
272
273 // Color by status
274 ImU32 fill_color = GetStatusColor(node.status);
275 const bool selected = (node.id == selected_node_);
276 const bool query_match =
277 (HasNonEmptyQuery() && IsNodeQueryMatch(node.id));
278 ImU32 border_color = selected
279 ? IM_COL32(255, 255, 100, 255)
280 : (query_match ? IM_COL32(220, 220, 220, 255)
281 : IM_COL32(60, 60, 60, 255));
282
283 draw_list->AddRectFilled(node_min, node_max, fill_color, 8.0f * zoom_);
284 draw_list->AddRect(node_min, node_max, border_color, 8.0f * zoom_, 0,
285 2.0f * zoom_);
286
287 // Node text
288 float font_size = 11.0f * zoom_;
289 if (font_size >= 6.0f) {
290 // ID label
291 draw_list->AddText(nullptr, font_size,
292 ImVec2(nx + 6 * zoom_, ny + 4 * zoom_),
293 IM_COL32(200, 200, 200, 255), node.id.c_str());
294 // Name (truncated)
295 std::string display_name = node.name;
296 if (display_name.length() > 25) {
297 display_name = display_name.substr(0, 22) + "...";
298 }
299 draw_list->AddText(nullptr, font_size,
300 ImVec2(nx + 6 * zoom_, ny + 18 * zoom_),
301 IM_COL32(255, 255, 255, 255), display_name.c_str());
302 }
303
304 // Click detection
305 if (is_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
306 if (mouse_pos.x >= node_min.x && mouse_pos.x <= node_max.x &&
307 mouse_pos.y >= node_min.y && mouse_pos.y <= node_max.y) {
308 selected_node_ = (selected_node_ == node.id) ? "" : node.id;
309 }
310 }
311 }
312
313 draw_list->PopClipRect();
314
315 // Detail sidebar
316 if (!selected_node_.empty() && sidebar_width > 0) {
317 ImGui::SameLine();
318 ImGui::BeginGroup();
319 DrawNodeDetail(*graph);
320 ImGui::EndGroup();
321 }
322 }
323
324 private:
325 static constexpr float kNodeWidth = 160.0f;
326 static constexpr float kNodeHeight = 40.0f;
327
329 switch (status) {
331 return IM_COL32(40, 120, 40, 220);
333 return IM_COL32(180, 160, 40, 220);
335 return IM_COL32(160, 40, 40, 220);
337 default:
338 return IM_COL32(60, 60, 60, 220);
339 }
340 }
341
343 const auto* node = graph.GetNode(selected_node_);
344 if (!node)
345 return;
346
347 ImGui::BeginChild("node_detail", ImVec2(240, 0), ImGuiChildFlags_Borders);
348
349 ImGui::TextWrapped("%s", node->name.c_str());
350 ImGui::TextDisabled("%s", node->id.c_str());
351 ImGui::Separator();
352
353 if (!node->flags.empty()) {
354 ImGui::Text(tr("Flags:"));
355 for (const auto& flag : node->flags) {
356 if (!flag.value.empty()) {
357 ImGui::BulletText("%s = %s", flag.name.c_str(), flag.value.c_str());
358 } else {
359 ImGui::BulletText("%s", flag.name.c_str());
360 }
361 }
362 }
363
364 if (!node->locations.empty()) {
365 ImGui::Spacing();
366 ImGui::Text(tr("Locations:"));
367 for (size_t i = 0; i < node->locations.size(); ++i) {
368 const auto& loc = node->locations[i];
369 const auto room_id = ParseIntLoose(loc.room_id);
370 const auto overworld_id = ParseIntLoose(loc.overworld_id);
371 const auto special_world_id = ParseIntLoose(loc.special_world_id);
372 const auto jump_map_id =
373 ResolveStoryLocationMapJumpTarget(overworld_id, special_world_id);
374 ImGui::PushID(static_cast<int>(i));
375
376 ImGui::BulletText("%s", loc.name.c_str());
377
378 if (room_id) {
379 ImGui::SameLine();
380 if (ImGui::SmallButton(tr("Room"))) {
381 PublishJumpToRoom(*room_id);
382 }
383 }
384 if (jump_map_id) {
385 ImGui::SameLine();
386 if (ImGui::SmallButton(tr("Map"))) {
387 PublishJumpToMap(*jump_map_id);
388 }
389 } else if (overworld_id || special_world_id) {
390 ImGui::SameLine();
391 ImGui::BeginDisabled();
392 ImGui::SmallButton(tr("Map"));
393 ImGui::EndDisabled();
394 if (ImGui::IsItemHovered()) {
395 ImGui::SetTooltip(
396 tr("Map target is outside the supported overworld range."));
397 }
398 }
399
400 if (!loc.room_id.empty() || !loc.overworld_id.empty() ||
401 !loc.special_world_id.empty() || !loc.entrance_id.empty()) {
402 ImGui::TextDisabled(
403 tr("room=%s map=%s special=%s entrance=%s"),
404 loc.room_id.empty() ? "-" : loc.room_id.c_str(),
405 loc.overworld_id.empty() ? "-" : loc.overworld_id.c_str(),
406 loc.special_world_id.empty() ? "-" : loc.special_world_id.c_str(),
407 loc.entrance_id.empty() ? "-" : loc.entrance_id.c_str());
408 }
409
410 ImGui::PopID();
411 }
412 }
413
414 if (!node->text_ids.empty()) {
415 ImGui::Spacing();
416 ImGui::Text(tr("Text IDs:"));
417 for (size_t i = 0; i < node->text_ids.size(); ++i) {
418 const auto& tid = node->text_ids[i];
419 ImGui::PushID(static_cast<int>(i));
420
421 ImGui::BulletText("%s", tid.c_str());
422 ImGui::SameLine();
423 if (ImGui::SmallButton(tr("Open"))) {
424 if (auto msg_id = ParseIntLoose(tid)) {
425 PublishJumpToMessage(*msg_id);
426 }
427 }
428 ImGui::SameLine();
429 if (ImGui::SmallButton(tr("Copy"))) {
430 ImGui::SetClipboardText(tid.c_str());
431 }
432
433 ImGui::PopID();
434 }
435 }
436
437 if (!node->scripts.empty()) {
438 ImGui::Spacing();
439 ImGui::Text(tr("Scripts:"));
440 for (size_t i = 0; i < node->scripts.size(); ++i) {
441 const auto& script = node->scripts[i];
442 ImGui::PushID(static_cast<int>(i));
443 ImGui::BulletText("%s", script.c_str());
444 ImGui::SameLine();
445 if (ImGui::SmallButton(tr("Open"))) {
447 }
448 ImGui::SameLine();
449 if (ImGui::SmallButton(tr("Copy"))) {
450 ImGui::SetClipboardText(script.c_str());
451 }
452 ImGui::PopID();
453 }
454 }
455
456 if (!node->notes.empty()) {
457 ImGui::Spacing();
458 ImGui::TextWrapped(tr("Notes: %s"), node->notes.c_str());
459 }
460
461 ImGui::EndChild();
462 }
463
464 static std::optional<int> ParseIntLoose(const std::string& input) {
465 // Trim whitespace.
466 size_t start = input.find_first_not_of(" \t\r\n");
467 if (start == std::string::npos)
468 return std::nullopt;
469 size_t end = input.find_last_not_of(" \t\r\n");
470 std::string trimmed = input.substr(start, end - start + 1);
471
472 try {
473 size_t idx = 0;
474 int value = std::stoi(trimmed, &idx, /*base=*/0);
475 if (idx != trimmed.size())
476 return std::nullopt;
477 return value;
478 } catch (...) {
479 return std::nullopt;
480 }
481 }
482
483 void PublishJumpToRoom(int room_id) const {
484 if (auto* bus = ContentRegistry::Context::event_bus()) {
485 bus->Publish(JumpToRoomRequestEvent::Create(room_id));
486 }
487 }
488
489 void PublishJumpToMap(int map_id) const {
490 if (auto* bus = ContentRegistry::Context::event_bus()) {
491 bus->Publish(JumpToMapRequestEvent::Create(map_id));
492 }
493 }
494
495 void PublishJumpToMessage(int message_id) const {
496 if (auto* bus = ContentRegistry::Context::event_bus()) {
497 bus->Publish(JumpToMessageRequestEvent::Create(message_id));
498 }
499 }
500
501 void PublishJumpToAssemblySymbol(const std::string& symbol) const {
502 if (auto* bus = ContentRegistry::Context::event_bus()) {
503 bus->Publish(JumpToAssemblySymbolRequestEvent::Create(symbol));
504 }
505 }
506
508 if (!manifest_)
509 return 0;
510 const auto prog_opt =
514 if (!prog_opt.has_value())
515 return 0;
516
517 const auto& s = *prog_opt;
518 return static_cast<uint64_t>(s.crystal_bitfield) |
519 (static_cast<uint64_t>(s.game_state) << 8) |
520 (static_cast<uint64_t>(s.oosprog) << 16) |
521 (static_cast<uint64_t>(s.oosprog2) << 24) |
522 (static_cast<uint64_t>(s.side_quest) << 32) |
523 (static_cast<uint64_t>(s.pendants) << 40);
524 }
525
527 (void)graph;
528
529 ImGui::Text(tr("Filter"));
530 ImGui::SameLine();
531 ImGui::SetNextItemWidth(260.0f);
532 if (ImGui::InputTextWithHint("##story_graph_filter",
533 "Search id/name/text/script/flag/room...",
534 &filter_query_)) {
535 filter_dirty_ = true;
536 }
537 ImGui::SameLine();
538 if (ImGui::SmallButton(tr("Clear"))) {
539 if (!filter_query_.empty()) {
540 filter_query_.clear();
541 filter_dirty_ = true;
542 }
543 }
544
545 ImGui::SameLine();
546 if (ImGui::Checkbox(tr("Hide non-matching"), &hide_non_matching_)) {
547 // Hiding doesn't change matches, but it can invalidate selection.
548 filter_dirty_ = true;
549 }
550
551 ImGui::SameLine();
552 bool toggles_changed = false;
553 toggles_changed |= ImGui::Checkbox(tr("Completed"), &show_completed_);
554 ImGui::SameLine();
555 toggles_changed |= ImGui::Checkbox(tr("Available"), &show_available_);
556 ImGui::SameLine();
557 toggles_changed |= ImGui::Checkbox(tr("Locked"), &show_locked_);
558 ImGui::SameLine();
559 toggles_changed |= ImGui::Checkbox(tr("Blocked"), &show_blocked_);
560 if (toggles_changed) {
561 filter_dirty_ = true;
562 }
563 }
564
565 static uint8_t StatusMask(bool completed, bool available, bool locked,
566 bool blocked) {
567 uint8_t mask = 0;
568 if (completed)
569 mask |= 1u << 0;
570 if (available)
571 mask |= 1u << 1;
572 if (locked)
573 mask |= 1u << 2;
574 if (blocked)
575 mask |= 1u << 3;
576 return mask;
577 }
578
579 bool HasNonEmptyQuery() const { return !filter_query_.empty(); }
580
581 bool IsNodeVisible(const std::string& id) const {
582 auto it = node_visible_by_id_.find(id);
583 return it != node_visible_by_id_.end() ? it->second : true;
584 }
585
586 bool IsNodeQueryMatch(const std::string& id) const {
587 auto it = node_query_match_by_id_.find(id);
588 return it != node_query_match_by_id_.end() ? it->second : false;
589 }
590
592 const uint8_t status_mask = StatusMask(show_completed_, show_available_,
594 const uint64_t progress_fp = ComputeProgressionFingerprint();
595
596 const size_t node_count = graph.nodes().size();
597 if (!filter_dirty_ && node_count == last_node_count_ &&
599 status_mask == last_status_mask_ && progress_fp == last_progress_fp_) {
600 return;
601 }
602
603 last_node_count_ = node_count;
605 last_status_mask_ = status_mask;
606 last_progress_fp_ = progress_fp;
607 filter_dirty_ = false;
608
610 filter.query = filter_query_;
615
617 node_visible_by_id_.clear();
618 node_query_match_by_id_.reserve(node_count);
619 node_visible_by_id_.reserve(node_count);
620
621 for (const auto& node : graph.nodes()) {
622 const bool query_match =
624 const bool visible =
625 query_match && core::StoryNodeStatusAllowed(node.status, filter);
626 node_query_match_by_id_[node.id] = query_match;
627 node_visible_by_id_[node.id] = visible;
628 }
629
630 // If we're hiding nodes and the selection becomes invisible, clear it to
631 // avoid a "ghost sidebar" pointing at a filtered-out node.
632 if (hide_non_matching_ && !selected_node_.empty() &&
634 selected_node_.clear();
635 }
636 }
637
639 if (!manifest_)
640 return;
641
643 options.filters = {
644 {"SRAM (.srm)", "srm"},
645 {"All Files", "*"},
646 };
647
648 std::string file_path =
650 if (file_path.empty()) {
651 return;
652 }
653
654 auto state_or =
658 if (!state_or.ok()) {
659 last_srm_error_ = std::string(state_or.status().message());
660 return;
661 }
662
663 if (auto* backend = GetProgressionBackend()) {
664 backend->SetProgressionState(*manifest_, *state_or);
665 } else {
667 }
668 loaded_srm_path_ = file_path;
669 last_srm_error_.clear();
670
671 // Status coloring changed; refresh filter cache visibility.
672 filter_dirty_ = true;
673 }
674
676 if (!manifest_)
677 return;
678 if (auto* backend = GetProgressionBackend()) {
679 backend->ClearProgressionState(*manifest_);
680 } else {
682 }
683 loaded_srm_path_.clear();
684 last_srm_error_.clear();
685 filter_dirty_ = true;
686 }
687
689 const bool connected = live_client_ && live_client_->IsConnected();
690 if (ImGui::SmallButton(tr("Sync Mesen"))) {
691 live_refresh_pending_.store(false);
693 }
694 if (ImGui::IsItemHovered()) {
695 ImGui::SetTooltip(tr("Read Oracle SRAM directly from connected Mesen2"));
696 }
697
698 ImGui::SameLine();
699 ImGui::Checkbox(tr("Live"), &live_sync_enabled_);
700 if (live_sync_enabled_) {
701 ImGui::SameLine();
702 ImGui::SetNextItemWidth(70.0f);
703 ImGui::SliderFloat("##StoryGraphLiveInterval",
704 &live_refresh_interval_seconds_, 0.05f, 0.5f, "%.2fs");
705 }
706
707 ImGui::SameLine();
708 if (connected) {
709 ImGui::TextDisabled(tr("Mesen: connected"));
710 } else {
711 ImGui::TextDisabled(tr("Mesen: disconnected"));
712 }
713
714 if (!live_sync_error_.empty()) {
715 ImGui::SameLine();
716 ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.35f, 1.0f),
717 tr("Live sync error"));
718 if (ImGui::IsItemHovered()) {
719 ImGui::SetTooltip("%s", live_sync_error_.c_str());
720 }
721 }
722 }
723
726 if (client == live_client_) {
727 return;
728 }
729
731 live_client_ = std::move(client);
733 live_sync_error_.clear();
734
735 if (live_client_ && live_client_->IsConnected()) {
736 live_refresh_pending_.store(true);
737 }
738 }
739
741 if (!live_sync_enabled_) {
742 return;
743 }
744 if (!live_client_ || !live_client_->IsConnected()) {
746 return;
747 }
748
749 if (live_listener_id_ == 0) {
750 live_listener_id_ = live_client_->AddEventListener(
751 [this](const emu::mesen::MesenEvent& event) {
752 if (event.type == "frame_complete" ||
753 event.type == "breakpoint_hit" || event.type == "all") {
754 live_refresh_pending_.store(true);
755 }
756 });
757 }
758
760 return;
761 }
762
763 const double now = ImGui::GetTime();
764 if ((now - last_subscribe_attempt_time_) < 1.0) {
765 return;
766 }
768
769 auto status = live_client_->Subscribe({"frame_complete", "breakpoint_hit"});
770 if (!status.ok()) {
771 live_sync_error_ = std::string(status.message());
772 return;
773 }
774
776 live_sync_error_.clear();
777 live_refresh_pending_.store(true);
778 }
779
781 if (!live_sync_enabled_) {
782 return;
783 }
784 if (!live_refresh_pending_.load()) {
785 return;
786 }
787 const double now = ImGui::GetTime();
789 return;
790 }
793 }
794 live_refresh_pending_.store(false);
795 }
796
798 if (!manifest_) {
799 return false;
800 }
801 if (!live_client_ || !live_client_->IsConnected()) {
802 live_sync_error_ = "Mesen client is not connected";
803 return false;
804 }
805
806 auto* backend = GetProgressionBackend();
807 if (backend == nullptr) {
808 live_sync_error_ = "No hack workflow backend is available";
809 return false;
810 }
811
812 auto state_or = backend->ReadProgressionStateFromLiveSram(*live_client_);
813 if (!state_or.ok()) {
814 live_sync_error_ = std::string(state_or.status().message());
815 return false;
816 }
817
818 backend->SetProgressionState(*manifest_, *state_or);
819 loaded_srm_path_ = "Mesen2 Live";
820 last_srm_error_.clear();
821 live_sync_error_.clear();
822 filter_dirty_ = true;
823 return true;
824 }
825
827 if (live_client_ && live_listener_id_ != 0) {
828 live_client_->RemoveEventListener(live_listener_id_);
829 }
832 }
833
837
841
845
847 if (!manifest_) {
848 return nullptr;
849 }
850 if (auto* backend = GetPlanningBackend()) {
851 return backend->GetStoryGraph(*manifest_);
852 }
854 }
855
856 core::HackManifest* manifest_ = nullptr;
857 std::string selected_node_;
858 float scroll_x_ = 0;
859 float scroll_y_ = 0;
860 float zoom_ = 1.0f;
861
862 // Filter state
863 std::string filter_query_;
864 bool hide_non_matching_ = false;
865 bool show_completed_ = true;
866 bool show_available_ = true;
867 bool show_locked_ = true;
868 bool show_blocked_ = true;
869
870 // Filter cache (recomputed only when query/toggles change)
871 bool filter_dirty_ = true;
872 size_t last_node_count_ = 0;
873 std::string last_filter_query_;
874 uint8_t last_status_mask_ = 0;
875 std::unordered_map<std::string, bool> node_query_match_by_id_;
876 std::unordered_map<std::string, bool> node_visible_by_id_;
877
878 // SRAM import state (purely UI; the actual progression state lives in HackManifest).
879 std::string loaded_srm_path_;
880 std::string last_srm_error_;
881
882 std::shared_ptr<emu::mesen::MesenSocketClient> live_client_;
884 bool live_sync_enabled_ = true;
885 bool live_subscription_active_ = false;
886 std::atomic<bool> live_refresh_pending_{false};
887 float live_refresh_interval_seconds_ = 0.10f;
888 double last_live_refresh_time_ = 0.0;
889 double last_subscribe_attempt_time_ = 0.0;
890 std::string live_sync_error_;
891
892 uint64_t last_progress_fp_ = 0;
893};
894
895} // namespace yaze::editor
896
897#endif // YAZE_APP_EDITOR_ORACLE_PANELS_STORY_EVENT_GRAPH_PANEL_H
Loads and queries the hack manifest JSON for yaze-ASM integration.
const ProjectRegistry & project_registry() const
bool HasProjectRegistry() const
std::optional< OracleProgressionState > oracle_progression_state() const
void SetOracleProgressionState(const OracleProgressionState &state)
The complete Oracle narrative progression graph.
const std::vector< StoryEventNode > & nodes() const
const StoryEventNode * GetNode(const std::string &id) const
workflow::HackWorkflowBackend * GetWorkflowBackend() const
WindowLifecycle GetWindowLifecycle() const override
Get the lifecycle category for this window.
bool IsNodeQueryMatch(const std::string &id) const
const core::StoryEventGraph * GetStoryGraph() const
std::unordered_map< std::string, bool > node_visible_by_id_
bool IsNodeVisible(const std::string &id) const
std::string GetId() const override
Unique identifier for this panel.
void DrawFilterControls(const core::StoryEventGraph &graph)
float GetPreferredWidth() const override
Get preferred width for this panel (optional)
void SetManifest(core::HackManifest *manifest)
Inject manifest pointer (called by host editor or lazy-resolved).
std::string GetEditorCategory() const override
Editor category this panel belongs to.
void PublishJumpToAssemblySymbol(const std::string &symbol) const
std::string GetIcon() const override
Material Design icon for this panel.
std::string GetWorkflowGroup() const override
Optional workflow group for hack-centric actions.
workflow::PlanningCapability * GetPlanningBackend() const
void PublishJumpToMessage(int message_id) const
emu::mesen::EventListenerId live_listener_id_
std::string GetDisplayName() const override
Human-readable name shown in menus and title bars.
std::shared_ptr< emu::mesen::MesenSocketClient > live_client_
workflow::ProgressionCapability * GetProgressionBackend() const
void DrawNodeDetail(const core::StoryEventGraph &graph)
void Draw(bool *) override
Draw the panel content.
static ImU32 GetStatusColor(core::StoryNodeStatus status)
static uint8_t StatusMask(bool completed, bool available, bool locked, bool blocked)
bool IsEnabled() const override
Check if this panel is currently enabled.
static std::optional< int > ParseIntLoose(const std::string &input)
void UpdateFilterCache(const core::StoryEventGraph &graph)
std::unordered_map< std::string, bool > node_query_match_by_id_
std::string GetWorkflowDescription() const override
Optional workflow description for menus/command palette.
WindowPresentationPolicy GetPresentationPolicy() const override
How this content is hosted and advertised by workspace chrome.
std::string GetDisabledTooltip() const override
Get tooltip text when panel is disabled.
virtual std::optional< core::OracleProgressionState > GetProgressionState(const core::HackManifest &manifest) const =0
virtual absl::StatusOr< core::OracleProgressionState > LoadProgressionStateFromFile(const std::string &filepath) const =0
static std::shared_ptr< MesenSocketClient > & GetClient()
static std::string ShowOpenFileDialog()
ShowOpenFileDialog opens a file dialog and returns the selected filepath. Uses global feature flag to...
#define ICON_MD_ACCOUNT_TREE
Definition icons.h:83
absl::StatusOr< OracleProgressionState > LoadOracleProgressionFromSrmFile(const std::string &srm_path)
bool StoryEventNodeMatchesQuery(const StoryEventNode &node, std::string_view query)
bool StoryNodeStatusAllowed(StoryNodeStatus status, const StoryEventNodeFilter &filter)
StoryNodeStatus
Completion status of a story event node for rendering.
::yaze::EventBus * event_bus()
Get the current EventBus instance.
workflow::PlanningCapability * hack_planning_backend()
::yaze::project::YazeProject * current_project()
Get the current project instance.
workflow::HackWorkflowBackend * hack_workflow_backend()
workflow::ProgressionCapability * hack_progression_backend()
Editors are the view controllers for the application.
WindowLifecycle
Defines lifecycle behavior for editor windows.
@ CrossEditor
User can pin to persist across editors.
std::optional< int > ResolveStoryLocationMapJumpTarget(const std::optional< int > &overworld_id, const std::optional< int > &special_world_id)
Filter options for StoryEventGraph node search in UI.
static JumpToAssemblySymbolRequestEvent Create(std::string sym, size_t session=0)
static JumpToMapRequestEvent Create(int map, size_t session=0)
static JumpToMessageRequestEvent Create(int message, size_t session=0)
static JumpToRoomRequestEvent Create(int room, size_t session=0)
static WindowPresentationPolicy OptionalPopOut()
Event from Mesen2 subscription.
std::vector< FileDialogFilter > filters
Definition file_util.h:17