yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
mesen_screenshot_panel.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <chrono>
5#include <cmath>
6#include <cstdint>
7#include <cstring>
8
9#include "absl/strings/str_format.h"
12#include "app/gui/core/icons.h"
13#include "imgui/imgui.h"
14
15#if defined(YAZE_WITH_LIBPNG)
16extern "C" {
17#include <png.h>
18}
19#endif
20
21namespace yaze {
22namespace editor {
23
24namespace {
25
26#if defined(YAZE_WITH_LIBPNG)
27// Minimal in-memory PNG read context for libpng callbacks.
28struct PngMemoryReader {
29 const uint8_t* data;
30 size_t offset;
31 size_t size;
32};
33
34void PngReadFromMemory(png_structp png_ptr, png_bytep out, png_size_t count) {
35 auto* reader = static_cast<PngMemoryReader*>(png_get_io_ptr(png_ptr));
36 if (reader->offset + count > reader->size) {
37 png_error(png_ptr, "Read past end of PNG data");
38 return;
39 }
40 std::memcpy(out, reader->data + reader->offset, count);
41 reader->offset += count;
42}
43#endif // YAZE_WITH_LIBPNG
44
45} // namespace
46
47// ---------------------------------------------------------------------------
48// Base64 decoder (RFC 4648, no external dependency)
49// ---------------------------------------------------------------------------
50
52 const std::string& encoded) {
53 // clang-format off
54 static constexpr int kTable[256] = {
55 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
56 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
57 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63,
58 52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-1,-1,-1,
59 -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,
60 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1,
61 -1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,
62 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1,
63 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
64 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
65 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
66 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
67 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
68 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
69 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
70 -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
71 };
72 // clang-format on
73
74 std::vector<uint8_t> out;
75 out.reserve((encoded.size() / 4) * 3);
76
77 uint32_t accum = 0;
78 int bits = 0;
79
80 for (unsigned char c : encoded) {
81 int val = kTable[c];
82 if (val < 0) continue; // Skip whitespace, padding, invalid chars
83 accum = (accum << 6) | static_cast<uint32_t>(val);
84 bits += 6;
85 if (bits >= 8) {
86 bits -= 8;
87 out.push_back(static_cast<uint8_t>((accum >> bits) & 0xFF));
88 }
89 }
90
91 return out;
92}
93
94// ---------------------------------------------------------------------------
95// PNG-to-RGBA decoder using libpng (already in the source tree)
96// ---------------------------------------------------------------------------
97
98bool MesenScreenshotPanel::DecodePngToRgba(const std::vector<uint8_t>& png_data,
99 std::vector<uint8_t>& rgba_out,
100 int& width_out, int& height_out) {
101#if !defined(YAZE_WITH_LIBPNG)
102 (void)png_data;
103 rgba_out.clear();
104 width_out = 0;
105 height_out = 0;
106 return false;
107#else
108 if (png_data.size() < 8) return false;
109
110 // Verify PNG signature
111 if (png_sig_cmp(reinterpret_cast<png_bytep>(
112 const_cast<uint8_t*>(png_data.data())),
113 0, 8) != 0) {
114 return false;
115 }
116
117 png_structp png_ptr =
118 png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
119 if (!png_ptr) return false;
120
121 png_infop info_ptr = png_create_info_struct(png_ptr);
122 if (!info_ptr) {
123 png_destroy_read_struct(&png_ptr, nullptr, nullptr);
124 return false;
125 }
126
127 if (setjmp(png_jmpbuf(png_ptr))) {
128 png_destroy_read_struct(&png_ptr, &info_ptr, nullptr);
129 return false;
130 }
131
132 PngMemoryReader reader{png_data.data(), 0, png_data.size()};
133 png_set_read_fn(png_ptr, &reader, PngReadFromMemory);
134
135 png_read_info(png_ptr, info_ptr);
136
137 png_uint_32 w = png_get_image_width(png_ptr, info_ptr);
138 png_uint_32 h = png_get_image_height(png_ptr, info_ptr);
139 png_byte color_type = png_get_color_type(png_ptr, info_ptr);
140 png_byte bit_depth = png_get_bit_depth(png_ptr, info_ptr);
141
142 // Normalize all formats to 8-bit RGBA
143 if (bit_depth == 16) png_set_strip_16(png_ptr);
144 if (color_type == PNG_COLOR_TYPE_PALETTE) png_set_palette_to_rgb(png_ptr);
145 if (color_type == PNG_COLOR_TYPE_GRAY && bit_depth < 8)
146 png_set_expand_gray_1_2_4_to_8(png_ptr);
147 if (png_get_valid(png_ptr, info_ptr, PNG_INFO_tRNS))
148 png_set_tRNS_to_alpha(png_ptr);
149 if (color_type == PNG_COLOR_TYPE_RGB || color_type == PNG_COLOR_TYPE_GRAY ||
150 color_type == PNG_COLOR_TYPE_PALETTE)
151 png_set_filler(png_ptr, 0xFF, PNG_FILLER_AFTER);
152 if (color_type == PNG_COLOR_TYPE_GRAY ||
153 color_type == PNG_COLOR_TYPE_GRAY_ALPHA)
154 png_set_gray_to_rgb(png_ptr);
155
156 png_read_update_info(png_ptr, info_ptr);
157
158 width_out = static_cast<int>(w);
159 height_out = static_cast<int>(h);
160 rgba_out.resize(w * h * 4);
161
162 std::vector<png_bytep> row_pointers(h);
163 for (png_uint_32 y = 0; y < h; ++y) {
164 row_pointers[y] = rgba_out.data() + y * w * 4;
165 }
166
167 png_read_image(png_ptr, row_pointers.data());
168 png_destroy_read_struct(&png_ptr, &info_ptr, nullptr);
169
170 return true;
171#endif // YAZE_WITH_LIBPNG
172}
173
174// ---------------------------------------------------------------------------
175// Construction / Destruction
176// ---------------------------------------------------------------------------
177
187
189
190// ---------------------------------------------------------------------------
191// Connection management (mirrors MesenDebugPanel)
192// ---------------------------------------------------------------------------
193
195 std::shared_ptr<emu::mesen::MesenSocketClient> client) {
196 client_ = std::move(client);
198}
199
201 return client_ && client_->IsConnected();
202}
203
205 if (!client_) {
207 }
208 auto status = client_->Connect();
209 if (!status.ok()) {
210 connection_error_ = std::string(status.message());
211 } else {
212 connection_error_.clear();
214 }
215}
216
217void MesenScreenshotPanel::ConnectToPath(const std::string& socket_path) {
218 if (!client_) {
220 }
221 auto status = client_->Connect(socket_path);
222 if (!status.ok()) {
223 connection_error_ = std::string(status.message());
224 } else {
225 connection_error_.clear();
227 }
228}
229
231 if (client_) {
232 client_->Disconnect();
233 }
234 streaming_ = false;
235}
236
239 if (!socket_paths_.empty()) {
240 if (selected_socket_index_ < 0 ||
241 selected_socket_index_ >= static_cast<int>(socket_paths_.size())) {
243 }
244 if (socket_path_buffer_[0] == '\0') {
245 std::snprintf(socket_path_buffer_, sizeof(socket_path_buffer_), "%s",
247 }
248 } else {
250 }
251}
252
253// ---------------------------------------------------------------------------
254// Texture management
255// ---------------------------------------------------------------------------
256
257void MesenScreenshotPanel::EnsureTexture(int width, int height) {
258 if (texture_ && texture_width_ == width && texture_height_ == height) {
259 return; // Reuse existing texture
260 }
262
263 // Create an SDL streaming texture in RGBA byte order.
264 // In the ImGui+SDL2 backend, SDL_Texture* is used directly as ImTextureID.
265 SDL_Renderer* renderer = nullptr;
266
267 // Get the renderer from the current SDL window (ImGui backend owns it)
268 SDL_Window* window = SDL_GetMouseFocus();
269 if (!window) {
270 window = SDL_GetKeyboardFocus();
271 }
272 if (window) {
273 renderer = SDL_GetRenderer(window);
274 }
275
276 if (!renderer) {
277 return; // No renderer available yet
278 }
279
280 // Use RGBA32 so the input byte order is RGBA on both little and big endian.
281 texture_ = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32,
282 SDL_TEXTUREACCESS_STREAMING, width, height);
283 if (texture_) {
284 SDL_SetTextureBlendMode(texture_, SDL_BLENDMODE_BLEND);
285 texture_width_ = width;
286 texture_height_ = height;
287 }
288}
289
290void MesenScreenshotPanel::UpdateTexture(const std::vector<uint8_t>& rgba,
291 int width, int height) {
292 EnsureTexture(width, height);
293 if (!texture_) return;
294
295 // Our decoder produces RGBA bytes; the texture uses SDL_PIXELFORMAT_RGBA32 so
296 // the byte order matches across endianness.
297 SDL_UpdateTexture(texture_, nullptr, rgba.data(), width * 4);
298}
299
301 if (texture_) {
302 SDL_DestroyTexture(texture_);
303 texture_ = nullptr;
304 texture_width_ = 0;
305 texture_height_ = 0;
306 }
307}
308
309// ---------------------------------------------------------------------------
310// Screenshot capture pipeline
311// ---------------------------------------------------------------------------
312
314 if (!IsConnected()) return;
315
316 auto t0 = std::chrono::steady_clock::now();
317
318 auto result = client_->Screenshot();
319 if (!result.ok()) {
320 frame_stale_ = true;
321 status_message_ = std::string(result.status().message());
322 return;
323 }
324
325 // Decode base64 -> PNG bytes -> RGBA pixels
326 std::vector<uint8_t> png_bytes = DecodeBase64(*result);
327 if (png_bytes.empty()) {
328 frame_stale_ = true;
329 status_message_ = "Base64 decode failed";
330 return;
331 }
332
333 std::vector<uint8_t> rgba;
334 int w = 0, h = 0;
335 if (!DecodePngToRgba(png_bytes, rgba, w, h)) {
336 frame_stale_ = true;
337 status_message_ = "PNG decode failed";
338 return;
339 }
340
341 // Upload to GPU texture
342 UpdateTexture(rgba, w, h);
343
344 auto t1 = std::chrono::steady_clock::now();
345 last_capture_latency_ms_ = std::chrono::duration<float, std::milli>(t1 - t0).count();
346
347 frame_width_ = w;
348 frame_height_ = h;
350 frame_stale_ = false;
351 status_message_.clear();
352}
353
354// ---------------------------------------------------------------------------
355// Main Draw
356// ---------------------------------------------------------------------------
357
359 ImGui::PushID("MesenScreenshotPanel");
360
361 // Timer-driven streaming capture
362 if (IsConnected() && streaming_) {
363 time_accumulator_ += ImGui::GetIO().DeltaTime;
364 float interval = 1.0f / target_fps_;
365 if (time_accumulator_ >= interval) {
367 time_accumulator_ = 0.0f;
368 }
369 }
370
372 if (ImGui::BeginChild("MesenScreenshot_Panel", ImVec2(0, 0), true)) {
373 if (ImGui::IsWindowAppearing()) {
375 }
377
378 if (IsConnected()) {
379 ImGui::Spacing();
381 ImGui::Spacing();
382 ImGui::Separator();
383 ImGui::Spacing();
385 ImGui::Spacing();
387 }
388 }
389 ImGui::EndChild();
391
392 ImGui::PopID();
393}
394
395// ---------------------------------------------------------------------------
396// Connection header (same pattern as MesenDebugPanel)
397// ---------------------------------------------------------------------------
398
400 const auto& theme = AgentUI::GetTheme();
401
402 ImGui::TextColored(theme.accent_color, "%s Mesen2 Screenshot Preview",
404
405 // Connection status indicator
406 ImGui::SameLine(ImGui::GetWindowWidth() - 100);
407 if (IsConnected()) {
408 float pulse = 0.7f + 0.3f * std::sin(ImGui::GetTime() * 2.0f);
409 ImVec4 color = ImVec4(0.1f, pulse, 0.3f, 1.0f);
410 ImGui::TextColored(color, "%s Connected", ICON_MD_CHECK_CIRCLE);
411 } else {
412 ImGui::TextColored(theme.status_error, "%s Disconnected", ICON_MD_ERROR);
413 }
414
415 ImGui::Separator();
416
417 if (!IsConnected()) {
418 ImGui::TextDisabled("Socket");
419 const char* preview =
421 selected_socket_index_ < static_cast<int>(socket_paths_.size()))
423 : "No sockets found";
424 ImGui::SetNextItemWidth(-40);
425 if (ImGui::BeginCombo("##ss_socket_combo", preview)) {
426 for (int i = 0; i < static_cast<int>(socket_paths_.size()); ++i) {
427 bool selected = (i == selected_socket_index_);
428 if (ImGui::Selectable(socket_paths_[i].c_str(), selected)) {
430 std::snprintf(socket_path_buffer_, sizeof(socket_path_buffer_), "%s",
431 socket_paths_[i].c_str());
432 }
433 if (selected) {
434 ImGui::SetItemDefaultFocus();
435 }
436 }
437 ImGui::EndCombo();
438 }
439 ImGui::SameLine();
440 if (ImGui::SmallButton(ICON_MD_REFRESH "##ss_refresh")) {
442 }
443
444 ImGui::TextDisabled("Path");
445 ImGui::SetNextItemWidth(-1);
446 ImGui::InputTextWithHint("##ss_socket_path", "/tmp/mesen2-12345.sock",
448
449 if (ImGui::Button(ICON_MD_LINK " Connect")) {
450 std::string path = socket_path_buffer_;
451 if (path.empty() && selected_socket_index_ >= 0 &&
452 selected_socket_index_ < static_cast<int>(socket_paths_.size())) {
454 }
455 if (path.empty()) {
456 Connect();
457 } else {
458 ConnectToPath(path);
459 }
460 }
461 ImGui::SameLine();
462 if (ImGui::SmallButton(ICON_MD_AUTO_MODE " Auto")) {
463 Connect();
464 }
465 if (!connection_error_.empty()) {
466 ImGui::Spacing();
467 ImGui::TextColored(theme.status_error, "%s", connection_error_.c_str());
468 }
469 } else {
470 if (ImGui::Button(ICON_MD_LINK_OFF " Disconnect")) {
471 Disconnect();
472 }
473 }
474}
475
476// ---------------------------------------------------------------------------
477// Controls toolbar
478// ---------------------------------------------------------------------------
479
481 // Play / Pause toggle
482 if (streaming_) {
483 if (ImGui::Button(ICON_MD_PAUSE " Pause")) {
484 streaming_ = false;
485 }
486 } else {
487 if (ImGui::Button(ICON_MD_PLAY_ARROW " Stream")) {
488 streaming_ = true;
489 time_accumulator_ = 999.0f; // Trigger immediate first capture
490 }
491 }
492
493 ImGui::SameLine();
494
495 // FPS slider
496 ImGui::SetNextItemWidth(120);
497 ImGui::SliderFloat("##ss_fps", &target_fps_, 1.0f, 30.0f, "%.0f FPS");
498
499 ImGui::SameLine();
500
501 // One-shot capture button
502 if (ImGui::Button(ICON_MD_PHOTO_CAMERA " Capture")) {
504 }
505}
506
507// ---------------------------------------------------------------------------
508// Preview area (aspect-ratio-preserved image)
509// ---------------------------------------------------------------------------
510
512 ImVec2 avail = ImGui::GetContentRegionAvail();
513
514 if (!texture_ || frame_width_ <= 0 || frame_height_ <= 0) {
515 // Placeholder when no frame is available
516 float placeholder_h = std::max(avail.y - 40.0f, 100.0f);
517 ImVec2 center(avail.x * 0.5f, placeholder_h * 0.5f);
518 ImGui::BeginChild("##ss_placeholder", ImVec2(0, placeholder_h), true);
519 ImGui::SetCursorPos(ImVec2(center.x - 80, center.y - 10));
520 ImGui::TextDisabled("No screenshot captured");
521 ImGui::EndChild();
522 return;
523 }
524
525 // Compute display size preserving SNES aspect ratio
526 float src_aspect =
527 static_cast<float>(frame_width_) / static_cast<float>(frame_height_);
528 float max_h = std::max(avail.y - 60.0f, 100.0f); // Reserve space for info
529 float display_w = avail.x;
530 float display_h = display_w / src_aspect;
531
532 if (display_h > max_h) {
533 display_h = max_h;
534 display_w = display_h * src_aspect;
535 }
536
537 // Center horizontally
538 float offset_x = (avail.x - display_w) * 0.5f;
539 if (offset_x > 0) {
540 ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset_x);
541 }
542
543 ImGui::Image(reinterpret_cast<ImTextureID>(texture_),
544 ImVec2(display_w, display_h), ImVec2(0, 0), ImVec2(1, 1));
545
546 // Stale indicator overlay
547 if (frame_stale_) {
548 ImVec2 img_min = ImGui::GetItemRectMin();
549 ImGui::GetWindowDrawList()->AddRectFilled(
550 img_min, ImVec2(img_min.x + 50, img_min.y + 20),
551 IM_COL32(200, 60, 60, 200));
552 ImGui::GetWindowDrawList()->AddText(ImVec2(img_min.x + 4, img_min.y + 2),
553 IM_COL32(255, 255, 255, 255), "Stale");
554 }
555
556 // Info line below the image
557 const auto& theme = AgentUI::GetTheme();
558 ImGui::TextColored(theme.text_secondary_color,
559 "Frame #%llu | %dx%d | Latency: %.1f ms",
562}
563
564// ---------------------------------------------------------------------------
565// Status bar
566// ---------------------------------------------------------------------------
567
569 const auto& theme = AgentUI::GetTheme();
570 ImGui::Separator();
571
572 if (IsConnected() && !socket_path_buffer_[0]) {
573 ImGui::TextColored(theme.text_secondary_color, "Connected (auto-detect)");
574 } else if (IsConnected()) {
575 ImGui::TextColored(theme.text_secondary_color, "Connected to %s",
577 }
578
579 if (streaming_) {
580 ImGui::SameLine();
581 float pulse = 0.7f + 0.3f * std::sin(ImGui::GetTime() * 3.0f);
582 ImGui::TextColored(ImVec4(0.2f, pulse, 0.2f, 1.0f),
583 ICON_MD_FIBER_MANUAL_RECORD " Streaming at %.0f FPS",
585 } else if (IsConnected()) {
586 ImGui::SameLine();
587 ImGui::TextDisabled("Paused");
588 }
589
590 if (!status_message_.empty()) {
591 ImGui::TextColored(theme.status_error, "%s", status_message_.c_str());
592 }
593}
594
595} // namespace editor
596} // namespace yaze
static std::vector< uint8_t > DecodeBase64(const std::string &encoded)
void SetClient(std::shared_ptr< emu::mesen::MesenSocketClient > client)
std::vector< std::string > socket_paths_
std::shared_ptr< emu::mesen::MesenSocketClient > client_
void EnsureTexture(int width, int height)
static bool DecodePngToRgba(const std::vector< uint8_t > &png_data, std::vector< uint8_t > &rgba_out, int &width_out, int &height_out)
void ConnectToPath(const std::string &socket_path)
void UpdateTexture(const std::vector< uint8_t > &rgba, int width, int height)
static void SetClient(std::shared_ptr< MesenSocketClient > client)
static std::shared_ptr< MesenSocketClient > GetOrCreate()
static std::vector< std::string > ListAvailableSockets()
List available Mesen2 sockets on the system.
#define ICON_MD_PAUSE
Definition icons.h:1389
#define ICON_MD_LINK
Definition icons.h:1090
#define ICON_MD_CAMERA_ALT
Definition icons.h:355
#define ICON_MD_PLAY_ARROW
Definition icons.h:1479
#define ICON_MD_LINK_OFF
Definition icons.h:1091
#define ICON_MD_REFRESH
Definition icons.h:1572
#define ICON_MD_ERROR
Definition icons.h:686
#define ICON_MD_AUTO_MODE
Definition icons.h:222
#define ICON_MD_CHECK_CIRCLE
Definition icons.h:400
#define ICON_MD_FIBER_MANUAL_RECORD
Definition icons.h:739
#define ICON_MD_PHOTO_CAMERA
Definition icons.h:1453
const AgentUITheme & GetTheme()