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
51std::vector<uint8_t> MesenScreenshotPanel::DecodeBase64(
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)
83 continue; // Skip whitespace, padding, invalid chars
84 accum = (accum << 6) | static_cast<uint32_t>(val);
85 bits += 6;
86 if (bits >= 8) {
87 bits -= 8;
88 out.push_back(static_cast<uint8_t>((accum >> bits) & 0xFF));
89 }
90 }
91
92 return out;
93}
94
95// ---------------------------------------------------------------------------
96// PNG-to-RGBA decoder using libpng (already in the source tree)
97// ---------------------------------------------------------------------------
98
99bool MesenScreenshotPanel::DecodePngToRgba(const std::vector<uint8_t>& png_data,
100 std::vector<uint8_t>& rgba_out,
101 int& width_out, int& height_out) {
102#if !defined(YAZE_WITH_LIBPNG)
103 (void)png_data;
104 rgba_out.clear();
105 width_out = 0;
106 height_out = 0;
107 return false;
108#else
109 if (png_data.size() < 8)
110 return false;
111
112 // Verify PNG signature
113 if (png_sig_cmp(
114 reinterpret_cast<png_bytep>(const_cast<uint8_t*>(png_data.data())), 0,
115 8) != 0) {
116 return false;
117 }
118
119 png_structp png_ptr =
120 png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
121 if (!png_ptr)
122 return false;
123
124 png_infop info_ptr = png_create_info_struct(png_ptr);
125 if (!info_ptr) {
126 png_destroy_read_struct(&png_ptr, nullptr, nullptr);
127 return false;
128 }
129
130 if (setjmp(png_jmpbuf(png_ptr))) {
131 png_destroy_read_struct(&png_ptr, &info_ptr, nullptr);
132 return false;
133 }
134
135 PngMemoryReader reader{png_data.data(), 0, png_data.size()};
136 png_set_read_fn(png_ptr, &reader, PngReadFromMemory);
137
138 png_read_info(png_ptr, info_ptr);
139
140 png_uint_32 w = png_get_image_width(png_ptr, info_ptr);
141 png_uint_32 h = png_get_image_height(png_ptr, info_ptr);
142 png_byte color_type = png_get_color_type(png_ptr, info_ptr);
143 png_byte bit_depth = png_get_bit_depth(png_ptr, info_ptr);
144
145 // Normalize all formats to 8-bit RGBA
146 if (bit_depth == 16)
147 png_set_strip_16(png_ptr);
148 if (color_type == PNG_COLOR_TYPE_PALETTE)
149 png_set_palette_to_rgb(png_ptr);
150 if (color_type == PNG_COLOR_TYPE_GRAY && bit_depth < 8)
151 png_set_expand_gray_1_2_4_to_8(png_ptr);
152 if (png_get_valid(png_ptr, info_ptr, PNG_INFO_tRNS))
153 png_set_tRNS_to_alpha(png_ptr);
154 if (color_type == PNG_COLOR_TYPE_RGB || color_type == PNG_COLOR_TYPE_GRAY ||
155 color_type == PNG_COLOR_TYPE_PALETTE)
156 png_set_filler(png_ptr, 0xFF, PNG_FILLER_AFTER);
157 if (color_type == PNG_COLOR_TYPE_GRAY ||
158 color_type == PNG_COLOR_TYPE_GRAY_ALPHA)
159 png_set_gray_to_rgb(png_ptr);
160
161 png_read_update_info(png_ptr, info_ptr);
162
163 width_out = static_cast<int>(w);
164 height_out = static_cast<int>(h);
165 rgba_out.resize(w * h * 4);
166
167 std::vector<png_bytep> row_pointers(h);
168 for (png_uint_32 y = 0; y < h; ++y) {
169 row_pointers[y] = rgba_out.data() + y * w * 4;
170 }
171
172 png_read_image(png_ptr, row_pointers.data());
173 png_destroy_read_struct(&png_ptr, &info_ptr, nullptr);
174
175 return true;
176#endif // YAZE_WITH_LIBPNG
177}
178
179// ---------------------------------------------------------------------------
180// Construction / Destruction
181// ---------------------------------------------------------------------------
182
186 if (!socket_paths_.empty()) {
188 std::snprintf(socket_path_buffer_, sizeof(socket_path_buffer_), "%s",
189 socket_paths_[0].c_str());
190 }
191}
192
195}
196
197// ---------------------------------------------------------------------------
198// Connection management (mirrors MesenDebugPanel)
199// ---------------------------------------------------------------------------
200
202 std::shared_ptr<emu::mesen::MesenSocketClient> client) {
203 client_ = std::move(client);
205}
206
208 return client_ && client_->IsConnected();
209}
210
212 if (!client_) {
214 }
215 auto status = client_->Connect();
216 if (!status.ok()) {
217 connection_error_ = std::string(status.message());
218 } else {
219 connection_error_.clear();
221 }
222}
223
224void MesenScreenshotPanel::ConnectToPath(const std::string& socket_path) {
225 if (!client_) {
227 }
228 auto status = client_->Connect(socket_path);
229 if (!status.ok()) {
230 connection_error_ = std::string(status.message());
231 } else {
232 connection_error_.clear();
234 }
235}
236
238 if (client_) {
239 client_->Disconnect();
240 }
241 streaming_ = false;
242}
243
246 if (!socket_paths_.empty()) {
247 if (selected_socket_index_ < 0 ||
248 selected_socket_index_ >= static_cast<int>(socket_paths_.size())) {
250 }
251 if (socket_path_buffer_[0] == '\0') {
252 std::snprintf(socket_path_buffer_, sizeof(socket_path_buffer_), "%s",
254 }
255 } else {
257 }
258}
259
260// ---------------------------------------------------------------------------
261// Texture management
262// ---------------------------------------------------------------------------
263
264void MesenScreenshotPanel::EnsureTexture(int width, int height) {
265 if (texture_ && texture_width_ == width && texture_height_ == height) {
266 return; // Reuse existing texture
267 }
269
270 // Create an SDL streaming texture in RGBA byte order.
271 // In the ImGui+SDL2 backend, SDL_Texture* is used directly as ImTextureID.
272 SDL_Renderer* renderer = nullptr;
273
274 // Get the renderer from the current SDL window (ImGui backend owns it)
275 SDL_Window* window = SDL_GetMouseFocus();
276 if (!window) {
277 window = SDL_GetKeyboardFocus();
278 }
279 if (window) {
280 renderer = SDL_GetRenderer(window);
281 }
282
283 if (!renderer) {
284 return; // No renderer available yet
285 }
286
287 // Use RGBA32 so the input byte order is RGBA on both little and big endian.
288 texture_ = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_RGBA32,
289 SDL_TEXTUREACCESS_STREAMING, width, height);
290 if (texture_) {
291 SDL_SetTextureBlendMode(texture_, SDL_BLENDMODE_BLEND);
292 texture_width_ = width;
293 texture_height_ = height;
294 }
295}
296
297void MesenScreenshotPanel::UpdateTexture(const std::vector<uint8_t>& rgba,
298 int width, int height) {
299 EnsureTexture(width, height);
300 if (!texture_)
301 return;
302
303 // Our decoder produces RGBA bytes; the texture uses SDL_PIXELFORMAT_RGBA32 so
304 // the byte order matches across endianness.
305 SDL_UpdateTexture(texture_, nullptr, rgba.data(), width * 4);
306}
307
309 if (texture_) {
310 SDL_DestroyTexture(texture_);
311 texture_ = nullptr;
312 texture_width_ = 0;
313 texture_height_ = 0;
314 }
315}
316
317// ---------------------------------------------------------------------------
318// Screenshot capture pipeline
319// ---------------------------------------------------------------------------
320
322 if (!IsConnected())
323 return;
324
325 auto t0 = std::chrono::steady_clock::now();
326
327 auto result = client_->Screenshot();
328 if (!result.ok()) {
329 frame_stale_ = true;
330 status_message_ = std::string(result.status().message());
331 return;
332 }
333
334 // Decode base64 -> PNG bytes -> RGBA pixels
335 std::vector<uint8_t> png_bytes = DecodeBase64(*result);
336 if (png_bytes.empty()) {
337 frame_stale_ = true;
338 status_message_ = "Base64 decode failed";
339 return;
340 }
341
342 std::vector<uint8_t> rgba;
343 int w = 0, h = 0;
344 if (!DecodePngToRgba(png_bytes, rgba, w, h)) {
345 frame_stale_ = true;
346 status_message_ = "PNG decode failed";
347 return;
348 }
349
350 // Upload to GPU texture
351 UpdateTexture(rgba, w, h);
352
353 auto t1 = std::chrono::steady_clock::now();
355 std::chrono::duration<float, std::milli>(t1 - t0).count();
356
357 frame_width_ = w;
358 frame_height_ = h;
360 frame_stale_ = false;
361 status_message_.clear();
362}
363
364// ---------------------------------------------------------------------------
365// Main Draw
366// ---------------------------------------------------------------------------
367
369 ImGui::PushID("MesenScreenshotPanel");
370
371 // Timer-driven streaming capture
372 if (IsConnected() && streaming_) {
373 time_accumulator_ += ImGui::GetIO().DeltaTime;
374 float interval = 1.0f / target_fps_;
375 if (time_accumulator_ >= interval) {
377 time_accumulator_ = 0.0f;
378 }
379 }
380
382 if (ImGui::BeginChild("MesenScreenshot_Panel", ImVec2(0, 0), true)) {
383 if (ImGui::IsWindowAppearing()) {
385 }
387
388 if (IsConnected()) {
389 ImGui::Spacing();
391 ImGui::Spacing();
392 ImGui::Separator();
393 ImGui::Spacing();
395 ImGui::Spacing();
397 }
398 }
399 ImGui::EndChild();
401
402 ImGui::PopID();
403}
404
405// ---------------------------------------------------------------------------
406// Connection header (same pattern as MesenDebugPanel)
407// ---------------------------------------------------------------------------
408
410 const auto& theme = AgentUI::GetTheme();
411
412 ImGui::TextColored(theme.accent_color, "%s Mesen2 Screenshot Preview",
414
415 // Connection status indicator
416 ImGui::SameLine(ImGui::GetWindowWidth() - 100);
417 if (IsConnected()) {
418 float pulse = 0.7f + 0.3f * std::sin(ImGui::GetTime() * 2.0f);
419 ImVec4 color = ImVec4(0.1f, pulse, 0.3f, 1.0f);
420 ImGui::TextColored(color, "%s Connected", ICON_MD_CHECK_CIRCLE);
421 } else {
422 ImGui::TextColored(theme.status_error, "%s Disconnected", ICON_MD_ERROR);
423 }
424
425 ImGui::Separator();
426
427 if (!IsConnected()) {
428 ImGui::TextDisabled("Socket");
429 const char* preview =
431 selected_socket_index_ < static_cast<int>(socket_paths_.size()))
433 : "No sockets found";
434 ImGui::SetNextItemWidth(-40);
435 if (ImGui::BeginCombo("##ss_socket_combo", preview)) {
436 for (int i = 0; i < static_cast<int>(socket_paths_.size()); ++i) {
437 bool selected = (i == selected_socket_index_);
438 if (ImGui::Selectable(socket_paths_[i].c_str(), selected)) {
440 std::snprintf(socket_path_buffer_, sizeof(socket_path_buffer_), "%s",
441 socket_paths_[i].c_str());
442 }
443 if (selected) {
444 ImGui::SetItemDefaultFocus();
445 }
446 }
447 ImGui::EndCombo();
448 }
449 ImGui::SameLine();
450 if (ImGui::SmallButton(ICON_MD_REFRESH "##ss_refresh")) {
452 }
453
454 ImGui::TextDisabled("Path");
455 ImGui::SetNextItemWidth(-1);
456 ImGui::InputTextWithHint("##ss_socket_path", "/tmp/mesen2-12345.sock",
458
459 if (ImGui::Button(ICON_MD_LINK " Connect")) {
460 std::string path = socket_path_buffer_;
461 if (path.empty() && selected_socket_index_ >= 0 &&
462 selected_socket_index_ < static_cast<int>(socket_paths_.size())) {
464 }
465 if (path.empty()) {
466 Connect();
467 } else {
468 ConnectToPath(path);
469 }
470 }
471 ImGui::SameLine();
472 if (ImGui::SmallButton(ICON_MD_AUTO_MODE " Auto")) {
473 Connect();
474 }
475 if (!connection_error_.empty()) {
476 ImGui::Spacing();
477 ImGui::TextColored(theme.status_error, "%s", connection_error_.c_str());
478 }
479 } else {
480 if (ImGui::Button(ICON_MD_LINK_OFF " Disconnect")) {
481 Disconnect();
482 }
483 }
484}
485
486// ---------------------------------------------------------------------------
487// Controls toolbar
488// ---------------------------------------------------------------------------
489
491 // Play / Pause toggle
492 if (streaming_) {
493 if (ImGui::Button(ICON_MD_PAUSE " Pause")) {
494 streaming_ = false;
495 }
496 } else {
497 if (ImGui::Button(ICON_MD_PLAY_ARROW " Stream")) {
498 streaming_ = true;
499 time_accumulator_ = 999.0f; // Trigger immediate first capture
500 }
501 }
502
503 ImGui::SameLine();
504
505 // FPS slider
506 ImGui::SetNextItemWidth(120);
507 ImGui::SliderFloat("##ss_fps", &target_fps_, 1.0f, 30.0f, "%.0f FPS");
508
509 ImGui::SameLine();
510
511 // One-shot capture button
512 if (ImGui::Button(ICON_MD_PHOTO_CAMERA " Capture")) {
514 }
515}
516
517// ---------------------------------------------------------------------------
518// Preview area (aspect-ratio-preserved image)
519// ---------------------------------------------------------------------------
520
522 ImVec2 avail = ImGui::GetContentRegionAvail();
523
524 if (!texture_ || frame_width_ <= 0 || frame_height_ <= 0) {
525 // Placeholder when no frame is available
526 float placeholder_h = std::max(avail.y - 40.0f, 100.0f);
527 ImVec2 center(avail.x * 0.5f, placeholder_h * 0.5f);
528 ImGui::BeginChild("##ss_placeholder", ImVec2(0, placeholder_h), true);
529 ImGui::SetCursorPos(ImVec2(center.x - 80, center.y - 10));
530 ImGui::TextDisabled("No screenshot captured");
531 ImGui::EndChild();
532 return;
533 }
534
535 // Compute display size preserving SNES aspect ratio
536 float src_aspect =
537 static_cast<float>(frame_width_) / static_cast<float>(frame_height_);
538 float max_h = std::max(avail.y - 60.0f, 100.0f); // Reserve space for info
539 float display_w = avail.x;
540 float display_h = display_w / src_aspect;
541
542 if (display_h > max_h) {
543 display_h = max_h;
544 display_w = display_h * src_aspect;
545 }
546
547 // Center horizontally
548 float offset_x = (avail.x - display_w) * 0.5f;
549 if (offset_x > 0) {
550 ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset_x);
551 }
552
553 ImGui::Image(reinterpret_cast<ImTextureID>(texture_),
554 ImVec2(display_w, display_h), ImVec2(0, 0), ImVec2(1, 1));
555
556 // Stale indicator overlay
557 if (frame_stale_) {
558 ImVec2 img_min = ImGui::GetItemRectMin();
559 ImGui::GetWindowDrawList()->AddRectFilled(
560 img_min, ImVec2(img_min.x + 50, img_min.y + 20),
561 IM_COL32(200, 60, 60, 200));
562 ImGui::GetWindowDrawList()->AddText(ImVec2(img_min.x + 4, img_min.y + 2),
563 IM_COL32(255, 255, 255, 255), "Stale");
564 }
565
566 // Info line below the image
567 const auto& theme = AgentUI::GetTheme();
568 ImGui::TextColored(
569 theme.text_secondary_color, "Frame #%llu | %dx%d | Latency: %.1f ms",
571}
572
573// ---------------------------------------------------------------------------
574// Status bar
575// ---------------------------------------------------------------------------
576
578 const auto& theme = AgentUI::GetTheme();
579 ImGui::Separator();
580
581 if (IsConnected() && !socket_path_buffer_[0]) {
582 ImGui::TextColored(theme.text_secondary_color, "Connected (auto-detect)");
583 } else if (IsConnected()) {
584 ImGui::TextColored(theme.text_secondary_color, "Connected to %s",
586 }
587
588 if (streaming_) {
589 ImGui::SameLine();
590 float pulse = 0.7f + 0.3f * std::sin(ImGui::GetTime() * 3.0f);
591 ImGui::TextColored(ImVec4(0.2f, pulse, 0.2f, 1.0f),
592 ICON_MD_FIBER_MANUAL_RECORD " Streaming at %.0f FPS",
594 } else if (IsConnected()) {
595 ImGui::SameLine();
596 ImGui::TextDisabled("Paused");
597 }
598
599 if (!status_message_.empty()) {
600 ImGui::TextColored(theme.status_error, "%s", status_message_.c_str());
601 }
602}
603
604} // namespace editor
605} // 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()