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"; }
58 std::string GetWorkflowGroup() const override { return "Planning"; }
59 std::string GetWorkflowDescription() const override {
60 return "Inspect narrative and progression dependencies for the active "
61 "project";
62 }
63 bool IsEnabled() const override {
65 auto* backend = GetPlanningBackend();
66 return project != nullptr && project->project_opened() &&
67 backend != nullptr;
68 }
69 std::string GetDisabledTooltip() const override {
70 return "Story graph data is not available for the active hack project";
71 }
75 float GetPreferredWidth() const override { return 600.0f; }
76
77 void Draw(bool* /*p_open*/) override {
78 if (!IsEnabled()) {
79 ImGui::TextDisabled("%s", GetDisabledTooltip().c_str());
80 return;
81 }
82
83 // Lazily resolve the manifest from the project context
84 if (!manifest_) {
86 if (auto* backend = GetWorkflowBackend()) {
87 manifest_ = backend->ResolveManifest(project);
88 } else if (project && project->hack_manifest.loaded()) {
89 manifest_ = &project->hack_manifest;
90 }
91 }
92
94 ImGui::TextDisabled(tr("No hack project loaded"));
95 ImGui::TextDisabled(
96 tr("Open a project with a hack manifest to view story events."));
97 return;
98 }
99
103
104 const auto* graph = GetStoryGraph();
105 if (graph == nullptr || !graph->loaded()) {
106 ImGui::TextDisabled(tr("No story events data available"));
107 return;
108 }
109
110 // Controls row
111 if (ImGui::Button(tr("Reset View"))) {
112 scroll_x_ = 0;
113 scroll_y_ = 0;
114 zoom_ = 1.0f;
115 }
116 ImGui::SameLine();
117 ImGui::SliderFloat(tr("Zoom"), &zoom_, 0.3f, 2.0f, "%.1f");
118 ImGui::SameLine();
119 ImGui::Text(tr("Nodes: %zu Edges: %zu"), graph->nodes().size(),
120 graph->edges().size());
121 ImGui::SameLine();
122 const auto prog_opt =
126 if (prog_opt.has_value()) {
127 ImGui::TextDisabled(tr("Crystals: %d State: %s"),
128 prog_opt->GetCrystalCount(),
129 prog_opt->GetGameStateName().c_str());
130 } else {
131 ImGui::TextDisabled(tr("No SRAM loaded"));
132 }
133
134 ImGui::SameLine();
135 if (ImGui::SmallButton(tr("Import .srm..."))) {
137 }
138 ImGui::SameLine();
139 if (ImGui::SmallButton(tr("Clear SRAM"))) {
141 }
142 ImGui::SameLine();
144 if (!loaded_srm_path_.empty()) {
145 const std::filesystem::path p(loaded_srm_path_);
146 ImGui::SameLine();
147 ImGui::TextDisabled(tr("SRM: %s"), p.filename().string().c_str());
148 if (ImGui::IsItemHovered()) {
149 ImGui::SetTooltip("%s", loaded_srm_path_.c_str());
150 }
151 }
152
153 if (!last_srm_error_.empty()) {
154 ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), tr("SRM error: %s"),
155 last_srm_error_.c_str());
156 }
157
158 ImGui::Separator();
159
160 DrawFilterControls(*graph);
161 UpdateFilterCache(*graph);
162
163 ImGui::Separator();
164
165 // Main canvas area
166 ImVec2 canvas_pos = ImGui::GetCursorScreenPos();
167 ImVec2 canvas_size = ImGui::GetContentRegionAvail();
168
169 // Reserve space for detail sidebar if a node is selected
170 float sidebar_width = selected_node_.empty() ? 0.0f : 250.0f;
171 canvas_size.x -= sidebar_width;
172
173 if (canvas_size.x < 100 || canvas_size.y < 100)
174 return;
175
176 ImGui::InvisibleButton(
177 "story_canvas", canvas_size,
178 ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight);
179
180 bool is_hovered = ImGui::IsItemHovered();
181 bool is_active = ImGui::IsItemActive();
182
183 // Pan with right mouse button
184 if (is_active && ImGui::IsMouseDragging(ImGuiMouseButton_Right)) {
185 ImVec2 delta = ImGui::GetIO().MouseDelta;
186 scroll_x_ += delta.x;
187 scroll_y_ += delta.y;
188 }
189
190 // Zoom with scroll wheel
191 if (is_hovered) {
192 float wheel = ImGui::GetIO().MouseWheel;
193 if (wheel != 0.0f) {
194 zoom_ *= (wheel > 0) ? 1.1f : 0.9f;
195 if (zoom_ < 0.3f)
196 zoom_ = 0.3f;
197 if (zoom_ > 2.0f)
198 zoom_ = 2.0f;
199 }
200 }
201
202 ImDrawList* draw_list = ImGui::GetWindowDrawList();
203
204 // Clip to canvas
205 draw_list->PushClipRect(
206 canvas_pos,
207 ImVec2(canvas_pos.x + canvas_size.x, canvas_pos.y + canvas_size.y),
208 true);
209
210 // Center offset
211 float cx = canvas_pos.x + canvas_size.x * 0.5f + scroll_x_;
212 float cy = canvas_pos.y + canvas_size.y * 0.5f + scroll_y_;
213
214 // Draw edges first (behind nodes)
215 for (const auto& edge : graph->edges()) {
216 const auto* from_node = graph->GetNode(edge.from);
217 const auto* to_node = graph->GetNode(edge.to);
218 if (!from_node || !to_node)
219 continue;
220 if (hide_non_matching_) {
221 if (!IsNodeVisible(edge.from) || !IsNodeVisible(edge.to))
222 continue;
223 }
224
225 ImVec2 p1(cx + from_node->pos_x * zoom_ + kNodeWidth * zoom_ * 0.5f,
226 cy + from_node->pos_y * zoom_);
227 ImVec2 p2(cx + to_node->pos_x * zoom_ - kNodeWidth * zoom_ * 0.5f,
228 cy + to_node->pos_y * zoom_);
229
230 // Bezier control points
231 float ctrl_dx = (p2.x - p1.x) * 0.4f;
232 ImVec2 cp1(p1.x + ctrl_dx, p1.y);
233 ImVec2 cp2(p2.x - ctrl_dx, p2.y);
234
235 draw_list->AddBezierCubic(p1, cp1, cp2, p2, IM_COL32(150, 150, 150, 180),
236 1.5f * zoom_);
237
238 // Arrow head
239 ImVec2 dir(p2.x - cp2.x, p2.y - cp2.y);
240 float len = sqrtf(dir.x * dir.x + dir.y * dir.y);
241 if (len > 0) {
242 dir.x /= len;
243 dir.y /= len;
244 float arrow_size = 8.0f * zoom_;
245 ImVec2 arrow1(p2.x - dir.x * arrow_size + dir.y * arrow_size * 0.4f,
246 p2.y - dir.y * arrow_size - dir.x * arrow_size * 0.4f);
247 ImVec2 arrow2(p2.x - dir.x * arrow_size - dir.y * arrow_size * 0.4f,
248 p2.y - dir.y * arrow_size + dir.x * arrow_size * 0.4f);
249 draw_list->AddTriangleFilled(p2, arrow1, arrow2,
250 IM_COL32(150, 150, 150, 200));
251 }
252 }
253
254 // Draw nodes
255 ImVec2 mouse_pos = ImGui::GetIO().MousePos;
256
257 for (const auto& node : graph->nodes()) {
258 if (hide_non_matching_ && !IsNodeVisible(node.id)) {
259 continue;
260 }
261
262 float nx = cx + node.pos_x * zoom_ - kNodeWidth * zoom_ * 0.5f;
263 float ny = cy + node.pos_y * zoom_ - kNodeHeight * zoom_ * 0.5f;
264 float nw = kNodeWidth * zoom_;
265 float nh = kNodeHeight * zoom_;
266
267 ImVec2 node_min(nx, ny);
268 ImVec2 node_max(nx + nw, ny + nh);
269
270 // Color by status
271 ImU32 fill_color = GetStatusColor(node.status);
272 const bool selected = (node.id == selected_node_);
273 const bool query_match =
274 (HasNonEmptyQuery() && IsNodeQueryMatch(node.id));
275 ImU32 border_color = selected
276 ? IM_COL32(255, 255, 100, 255)
277 : (query_match ? IM_COL32(220, 220, 220, 255)
278 : IM_COL32(60, 60, 60, 255));
279
280 draw_list->AddRectFilled(node_min, node_max, fill_color, 8.0f * zoom_);
281 draw_list->AddRect(node_min, node_max, border_color, 8.0f * zoom_, 0,
282 2.0f * zoom_);
283
284 // Node text
285 float font_size = 11.0f * zoom_;
286 if (font_size >= 6.0f) {
287 // ID label
288 draw_list->AddText(nullptr, font_size,
289 ImVec2(nx + 6 * zoom_, ny + 4 * zoom_),
290 IM_COL32(200, 200, 200, 255), node.id.c_str());
291 // Name (truncated)
292 std::string display_name = node.name;
293 if (display_name.length() > 25) {
294 display_name = display_name.substr(0, 22) + "...";
295 }
296 draw_list->AddText(nullptr, font_size,
297 ImVec2(nx + 6 * zoom_, ny + 18 * zoom_),
298 IM_COL32(255, 255, 255, 255), display_name.c_str());
299 }
300
301 // Click detection
302 if (is_hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
303 if (mouse_pos.x >= node_min.x && mouse_pos.x <= node_max.x &&
304 mouse_pos.y >= node_min.y && mouse_pos.y <= node_max.y) {
305 selected_node_ = (selected_node_ == node.id) ? "" : node.id;
306 }
307 }
308 }
309
310 draw_list->PopClipRect();
311
312 // Detail sidebar
313 if (!selected_node_.empty() && sidebar_width > 0) {
314 ImGui::SameLine();
315 ImGui::BeginGroup();
316 DrawNodeDetail(*graph);
317 ImGui::EndGroup();
318 }
319 }
320
321 private:
322 static constexpr float kNodeWidth = 160.0f;
323 static constexpr float kNodeHeight = 40.0f;
324
326 switch (status) {
328 return IM_COL32(40, 120, 40, 220);
330 return IM_COL32(180, 160, 40, 220);
332 return IM_COL32(160, 40, 40, 220);
334 default:
335 return IM_COL32(60, 60, 60, 220);
336 }
337 }
338
340 const auto* node = graph.GetNode(selected_node_);
341 if (!node)
342 return;
343
344 ImGui::BeginChild("node_detail", ImVec2(240, 0), ImGuiChildFlags_Borders);
345
346 ImGui::TextWrapped("%s", node->name.c_str());
347 ImGui::TextDisabled("%s", node->id.c_str());
348 ImGui::Separator();
349
350 if (!node->flags.empty()) {
351 ImGui::Text(tr("Flags:"));
352 for (const auto& flag : node->flags) {
353 if (!flag.value.empty()) {
354 ImGui::BulletText("%s = %s", flag.name.c_str(), flag.value.c_str());
355 } else {
356 ImGui::BulletText("%s", flag.name.c_str());
357 }
358 }
359 }
360
361 if (!node->locations.empty()) {
362 ImGui::Spacing();
363 ImGui::Text(tr("Locations:"));
364 for (size_t i = 0; i < node->locations.size(); ++i) {
365 const auto& loc = node->locations[i];
366 const auto room_id = ParseIntLoose(loc.room_id);
367 const auto overworld_id = ParseIntLoose(loc.overworld_id);
368 const auto special_world_id = ParseIntLoose(loc.special_world_id);
369 const auto jump_map_id =
370 ResolveStoryLocationMapJumpTarget(overworld_id, special_world_id);
371 ImGui::PushID(static_cast<int>(i));
372
373 ImGui::BulletText("%s", loc.name.c_str());
374
375 if (room_id) {
376 ImGui::SameLine();
377 if (ImGui::SmallButton(tr("Room"))) {
378 PublishJumpToRoom(*room_id);
379 }
380 }
381 if (jump_map_id) {
382 ImGui::SameLine();
383 if (ImGui::SmallButton(tr("Map"))) {
384 PublishJumpToMap(*jump_map_id);
385 }
386 } else if (overworld_id || special_world_id) {
387 ImGui::SameLine();
388 ImGui::BeginDisabled();
389 ImGui::SmallButton(tr("Map"));
390 ImGui::EndDisabled();
391 if (ImGui::IsItemHovered()) {
392 ImGui::SetTooltip(
393 tr("Map target is outside the supported overworld range."));
394 }
395 }
396
397 if (!loc.room_id.empty() || !loc.overworld_id.empty() ||
398 !loc.special_world_id.empty() || !loc.entrance_id.empty()) {
399 ImGui::TextDisabled(
400 tr("room=%s map=%s special=%s entrance=%s"),
401 loc.room_id.empty() ? "-" : loc.room_id.c_str(),
402 loc.overworld_id.empty() ? "-" : loc.overworld_id.c_str(),
403 loc.special_world_id.empty() ? "-" : loc.special_world_id.c_str(),
404 loc.entrance_id.empty() ? "-" : loc.entrance_id.c_str());
405 }
406
407 ImGui::PopID();
408 }
409 }
410
411 if (!node->text_ids.empty()) {
412 ImGui::Spacing();
413 ImGui::Text(tr("Text IDs:"));
414 for (size_t i = 0; i < node->text_ids.size(); ++i) {
415 const auto& tid = node->text_ids[i];
416 ImGui::PushID(static_cast<int>(i));
417
418 ImGui::BulletText("%s", tid.c_str());
419 ImGui::SameLine();
420 if (ImGui::SmallButton(tr("Open"))) {
421 if (auto msg_id = ParseIntLoose(tid)) {
422 PublishJumpToMessage(*msg_id);
423 }
424 }
425 ImGui::SameLine();
426 if (ImGui::SmallButton(tr("Copy"))) {
427 ImGui::SetClipboardText(tid.c_str());
428 }
429
430 ImGui::PopID();
431 }
432 }
433
434 if (!node->scripts.empty()) {
435 ImGui::Spacing();
436 ImGui::Text(tr("Scripts:"));
437 for (size_t i = 0; i < node->scripts.size(); ++i) {
438 const auto& script = node->scripts[i];
439 ImGui::PushID(static_cast<int>(i));
440 ImGui::BulletText("%s", script.c_str());
441 ImGui::SameLine();
442 if (ImGui::SmallButton(tr("Open"))) {
444 }
445 ImGui::SameLine();
446 if (ImGui::SmallButton(tr("Copy"))) {
447 ImGui::SetClipboardText(script.c_str());
448 }
449 ImGui::PopID();
450 }
451 }
452
453 if (!node->notes.empty()) {
454 ImGui::Spacing();
455 ImGui::TextWrapped(tr("Notes: %s"), node->notes.c_str());
456 }
457
458 ImGui::EndChild();
459 }
460
461 static std::optional<int> ParseIntLoose(const std::string& input) {
462 // Trim whitespace.
463 size_t start = input.find_first_not_of(" \t\r\n");
464 if (start == std::string::npos)
465 return std::nullopt;
466 size_t end = input.find_last_not_of(" \t\r\n");
467 std::string trimmed = input.substr(start, end - start + 1);
468
469 try {
470 size_t idx = 0;
471 int value = std::stoi(trimmed, &idx, /*base=*/0);
472 if (idx != trimmed.size())
473 return std::nullopt;
474 return value;
475 } catch (...) {
476 return std::nullopt;
477 }
478 }
479
480 void PublishJumpToRoom(int room_id) const {
481 if (auto* bus = ContentRegistry::Context::event_bus()) {
482 bus->Publish(JumpToRoomRequestEvent::Create(room_id));
483 }
484 }
485
486 void PublishJumpToMap(int map_id) const {
487 if (auto* bus = ContentRegistry::Context::event_bus()) {
488 bus->Publish(JumpToMapRequestEvent::Create(map_id));
489 }
490 }
491
492 void PublishJumpToMessage(int message_id) const {
493 if (auto* bus = ContentRegistry::Context::event_bus()) {
494 bus->Publish(JumpToMessageRequestEvent::Create(message_id));
495 }
496 }
497
498 void PublishJumpToAssemblySymbol(const std::string& symbol) const {
499 if (auto* bus = ContentRegistry::Context::event_bus()) {
500 bus->Publish(JumpToAssemblySymbolRequestEvent::Create(symbol));
501 }
502 }
503
505 if (!manifest_)
506 return 0;
507 const auto prog_opt =
511 if (!prog_opt.has_value())
512 return 0;
513
514 const auto& s = *prog_opt;
515 return static_cast<uint64_t>(s.crystal_bitfield) |
516 (static_cast<uint64_t>(s.game_state) << 8) |
517 (static_cast<uint64_t>(s.oosprog) << 16) |
518 (static_cast<uint64_t>(s.oosprog2) << 24) |
519 (static_cast<uint64_t>(s.side_quest) << 32) |
520 (static_cast<uint64_t>(s.pendants) << 40);
521 }
522
524 (void)graph;
525
526 ImGui::Text(tr("Filter"));
527 ImGui::SameLine();
528 ImGui::SetNextItemWidth(260.0f);
529 if (ImGui::InputTextWithHint("##story_graph_filter",
530 "Search id/name/text/script/flag/room...",
531 &filter_query_)) {
532 filter_dirty_ = true;
533 }
534 ImGui::SameLine();
535 if (ImGui::SmallButton(tr("Clear"))) {
536 if (!filter_query_.empty()) {
537 filter_query_.clear();
538 filter_dirty_ = true;
539 }
540 }
541
542 ImGui::SameLine();
543 if (ImGui::Checkbox(tr("Hide non-matching"), &hide_non_matching_)) {
544 // Hiding doesn't change matches, but it can invalidate selection.
545 filter_dirty_ = true;
546 }
547
548 ImGui::SameLine();
549 bool toggles_changed = false;
550 toggles_changed |= ImGui::Checkbox(tr("Completed"), &show_completed_);
551 ImGui::SameLine();
552 toggles_changed |= ImGui::Checkbox(tr("Available"), &show_available_);
553 ImGui::SameLine();
554 toggles_changed |= ImGui::Checkbox(tr("Locked"), &show_locked_);
555 ImGui::SameLine();
556 toggles_changed |= ImGui::Checkbox(tr("Blocked"), &show_blocked_);
557 if (toggles_changed) {
558 filter_dirty_ = true;
559 }
560 }
561
562 static uint8_t StatusMask(bool completed, bool available, bool locked,
563 bool blocked) {
564 uint8_t mask = 0;
565 if (completed)
566 mask |= 1u << 0;
567 if (available)
568 mask |= 1u << 1;
569 if (locked)
570 mask |= 1u << 2;
571 if (blocked)
572 mask |= 1u << 3;
573 return mask;
574 }
575
576 bool HasNonEmptyQuery() const { return !filter_query_.empty(); }
577
578 bool IsNodeVisible(const std::string& id) const {
579 auto it = node_visible_by_id_.find(id);
580 return it != node_visible_by_id_.end() ? it->second : true;
581 }
582
583 bool IsNodeQueryMatch(const std::string& id) const {
584 auto it = node_query_match_by_id_.find(id);
585 return it != node_query_match_by_id_.end() ? it->second : false;
586 }
587
589 const uint8_t status_mask = StatusMask(show_completed_, show_available_,
591 const uint64_t progress_fp = ComputeProgressionFingerprint();
592
593 const size_t node_count = graph.nodes().size();
594 if (!filter_dirty_ && node_count == last_node_count_ &&
596 status_mask == last_status_mask_ && progress_fp == last_progress_fp_) {
597 return;
598 }
599
600 last_node_count_ = node_count;
602 last_status_mask_ = status_mask;
603 last_progress_fp_ = progress_fp;
604 filter_dirty_ = false;
605
607 filter.query = filter_query_;
612
614 node_visible_by_id_.clear();
615 node_query_match_by_id_.reserve(node_count);
616 node_visible_by_id_.reserve(node_count);
617
618 for (const auto& node : graph.nodes()) {
619 const bool query_match =
621 const bool visible =
622 query_match && core::StoryNodeStatusAllowed(node.status, filter);
623 node_query_match_by_id_[node.id] = query_match;
624 node_visible_by_id_[node.id] = visible;
625 }
626
627 // If we're hiding nodes and the selection becomes invisible, clear it to
628 // avoid a "ghost sidebar" pointing at a filtered-out node.
629 if (hide_non_matching_ && !selected_node_.empty() &&
631 selected_node_.clear();
632 }
633 }
634
636 if (!manifest_)
637 return;
638
640 options.filters = {
641 {"SRAM (.srm)", "srm"},
642 {"All Files", "*"},
643 };
644
645 std::string file_path =
647 if (file_path.empty()) {
648 return;
649 }
650
651 auto state_or =
655 if (!state_or.ok()) {
656 last_srm_error_ = std::string(state_or.status().message());
657 return;
658 }
659
660 if (auto* backend = GetProgressionBackend()) {
661 backend->SetProgressionState(*manifest_, *state_or);
662 } else {
664 }
665 loaded_srm_path_ = file_path;
666 last_srm_error_.clear();
667
668 // Status coloring changed; refresh filter cache visibility.
669 filter_dirty_ = true;
670 }
671
673 if (!manifest_)
674 return;
675 if (auto* backend = GetProgressionBackend()) {
676 backend->ClearProgressionState(*manifest_);
677 } else {
679 }
680 loaded_srm_path_.clear();
681 last_srm_error_.clear();
682 filter_dirty_ = true;
683 }
684
686 const bool connected = live_client_ && live_client_->IsConnected();
687 if (ImGui::SmallButton(tr("Sync Mesen"))) {
688 live_refresh_pending_.store(false);
690 }
691 if (ImGui::IsItemHovered()) {
692 ImGui::SetTooltip(tr("Read Oracle SRAM directly from connected Mesen2"));
693 }
694
695 ImGui::SameLine();
696 ImGui::Checkbox(tr("Live"), &live_sync_enabled_);
697 if (live_sync_enabled_) {
698 ImGui::SameLine();
699 ImGui::SetNextItemWidth(70.0f);
700 ImGui::SliderFloat("##StoryGraphLiveInterval",
701 &live_refresh_interval_seconds_, 0.05f, 0.5f, "%.2fs");
702 }
703
704 ImGui::SameLine();
705 if (connected) {
706 ImGui::TextDisabled(tr("Mesen: connected"));
707 } else {
708 ImGui::TextDisabled(tr("Mesen: disconnected"));
709 }
710
711 if (!live_sync_error_.empty()) {
712 ImGui::SameLine();
713 ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.35f, 1.0f),
714 tr("Live sync error"));
715 if (ImGui::IsItemHovered()) {
716 ImGui::SetTooltip("%s", live_sync_error_.c_str());
717 }
718 }
719 }
720
723 if (client == live_client_) {
724 return;
725 }
726
728 live_client_ = std::move(client);
730 live_sync_error_.clear();
731
732 if (live_client_ && live_client_->IsConnected()) {
733 live_refresh_pending_.store(true);
734 }
735 }
736
738 if (!live_sync_enabled_) {
739 return;
740 }
741 if (!live_client_ || !live_client_->IsConnected()) {
743 return;
744 }
745
746 if (live_listener_id_ == 0) {
747 live_listener_id_ = live_client_->AddEventListener(
748 [this](const emu::mesen::MesenEvent& event) {
749 if (event.type == "frame_complete" ||
750 event.type == "breakpoint_hit" || event.type == "all") {
751 live_refresh_pending_.store(true);
752 }
753 });
754 }
755
757 return;
758 }
759
760 const double now = ImGui::GetTime();
761 if ((now - last_subscribe_attempt_time_) < 1.0) {
762 return;
763 }
765
766 auto status = live_client_->Subscribe({"frame_complete", "breakpoint_hit"});
767 if (!status.ok()) {
768 live_sync_error_ = std::string(status.message());
769 return;
770 }
771
773 live_sync_error_.clear();
774 live_refresh_pending_.store(true);
775 }
776
778 if (!live_sync_enabled_) {
779 return;
780 }
781 if (!live_refresh_pending_.load()) {
782 return;
783 }
784 const double now = ImGui::GetTime();
786 return;
787 }
790 }
791 live_refresh_pending_.store(false);
792 }
793
795 if (!manifest_) {
796 return false;
797 }
798 if (!live_client_ || !live_client_->IsConnected()) {
799 live_sync_error_ = "Mesen client is not connected";
800 return false;
801 }
802
803 auto* backend = GetProgressionBackend();
804 if (backend == nullptr) {
805 live_sync_error_ = "No hack workflow backend is available";
806 return false;
807 }
808
809 auto state_or = backend->ReadProgressionStateFromLiveSram(*live_client_);
810 if (!state_or.ok()) {
811 live_sync_error_ = std::string(state_or.status().message());
812 return false;
813 }
814
815 backend->SetProgressionState(*manifest_, *state_or);
816 loaded_srm_path_ = "Mesen2 Live";
817 last_srm_error_.clear();
818 live_sync_error_.clear();
819 filter_dirty_ = true;
820 return true;
821 }
822
824 if (live_client_ && live_listener_id_ != 0) {
825 live_client_->RemoveEventListener(live_listener_id_);
826 }
829 }
830
834
838
842
844 if (!manifest_) {
845 return nullptr;
846 }
847 if (auto* backend = GetPlanningBackend()) {
848 return backend->GetStoryGraph(*manifest_);
849 }
851 }
852
853 core::HackManifest* manifest_ = nullptr;
854 std::string selected_node_;
855 float scroll_x_ = 0;
856 float scroll_y_ = 0;
857 float zoom_ = 1.0f;
858
859 // Filter state
860 std::string filter_query_;
861 bool hide_non_matching_ = false;
862 bool show_completed_ = true;
863 bool show_available_ = true;
864 bool show_locked_ = true;
865 bool show_blocked_ = true;
866
867 // Filter cache (recomputed only when query/toggles change)
868 bool filter_dirty_ = true;
869 size_t last_node_count_ = 0;
870 std::string last_filter_query_;
871 uint8_t last_status_mask_ = 0;
872 std::unordered_map<std::string, bool> node_query_match_by_id_;
873 std::unordered_map<std::string, bool> node_visible_by_id_;
874
875 // SRAM import state (purely UI; the actual progression state lives in HackManifest).
876 std::string loaded_srm_path_;
877 std::string last_srm_error_;
878
879 std::shared_ptr<emu::mesen::MesenSocketClient> live_client_;
881 bool live_sync_enabled_ = true;
882 bool live_subscription_active_ = false;
883 std::atomic<bool> live_refresh_pending_{false};
884 float live_refresh_interval_seconds_ = 0.10f;
885 double last_live_refresh_time_ = 0.0;
886 double last_subscribe_attempt_time_ = 0.0;
887 std::string live_sync_error_;
888
889 uint64_t last_progress_fp_ = 0;
890};
891
892} // namespace yaze::editor
893
894#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.
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)
Event from Mesen2 subscription.
std::vector< FileDialogFilter > filters
Definition file_util.h:17