From bda85eea50036c0d434afe2ddd74f27dacebeff6 Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 30 Sep 2026 12:43:03 +0900 Subject: [PATCH] port: grp.SaveScreenShot via next-frame swapchain readback + JPEG CPythonGraphic::SaveScreenShot queues a readback of the next presented frame (ScreenShotHost -> VulkanWindow::request_frame); WriteScreenShot runs the rest of 40250's function verbatim (X8R8G8B8 rows, SaveJPEG, EXIF tag). jpeg_save is encoded with stb_image_write. SHGetSpecialFolderPath returns $HOME/Documents (else cwd); CreateDirectory/_access/jpeg_save accept '\\'. Co-Authored-By: Claude Opus 5.5 --- audit/history.jsonl | 2 + audit/port-map/EterLib/JpegFile.cpp.json | 78 + .../EterPythonLib/PythonGraphic.cpp.json | 25 +- src/host/live_client.cpp | 7 + src/host/sdl_window.cpp | 6 + src/host/vk_device.cpp | 1 + src/host/vk_frame.cpp | 16 +- src/host/vulkan_window.h | 11 +- src/platform/EterLib/JpegFile.cpp | 37 + src/platform/EterPythonLib/PythonGraphic.cpp | 160 +- src/platform/EterPythonLib/ScreenShotHost.h | 25 + src/platform/third_party/stb_image_write.h | 1724 +++++++++++++++++ src/port/CMakeLists.txt | 5 + src/port/EterLib/JpegFile.h | 9 + src/port/common/Win32Crt.cpp | 13 +- src/port/common/shim/win32/shlobj.h | 26 +- tests/port/port_screenshot_test.cpp | 78 + 17 files changed, 2211 insertions(+), 12 deletions(-) create mode 100644 audit/port-map/EterLib/JpegFile.cpp.json create mode 100644 src/platform/EterLib/JpegFile.cpp create mode 100644 src/platform/EterPythonLib/ScreenShotHost.h create mode 100644 src/platform/third_party/stb_image_write.h create mode 100644 src/port/EterLib/JpegFile.h create mode 100644 tests/port/port_screenshot_test.cpp diff --git a/audit/history.jsonl b/audit/history.jsonl index b8b16955..c00bccc4 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -599,3 +599,5 @@ {"date": "2026-09-30T03:32:49+00:00", "unit": "UserInterface/PythonApplicationLogo.cpp", "action": "correction: N_A: CPythonApplication::OnLogo* has no caller: intrologo.py calls app.OnLogoOpen/Update/Render/Close, but those bind appLogo* in PythonApplicationModule.cpp, whose CPythonApplication::OnLogo* calls are commented out", "evidence": "LC_ALL=C grep app.OnLogo over the root .py (intrologo.py); PythonApplicationModule.cpp:1165-1195 (the earlier grep ran without LC_ALL=C and missed the CP949 scripts)"} {"date": "2026-09-30T03:32:49+00:00", "unit": "UserInterface/MovieMan.cpp", "action": "correction: N_A: reached at boot through prototype.py SetLogoPhase -> intrologo.py app.OnLogoOpen(\"logo1.avi\"/\"logo2.avi\") -> CMovieMan::PlayLogo -> PlayMovie(\\\\logoN.avi), but the 40250 Client ships no .avi, so RenderFileToMMStream fails and nothing is shown; PlayIntro/PlayTutorial have no caller. The port stub matches that outcome", "evidence": "find Client -iname *.avi: none; LC_ALL=C grep PlayIntro/PlayTutorial over ClientVS22/source (the earlier grep ran without LC_ALL=C and missed the CP949 scripts)"} {"date": "2026-09-30T03:32:49+00:00", "unit": "UserInterface/PythonApplicationWebPage.cpp", "action": "correction: DIVERGENT: reachable: game.py __InGameShop_Show -> interfacemodule.OpenWebWindow -> uiweb.py app.ShowWebPage when constInfo.IN_GAME_SHOP_ENABLE (localeinfo sets it per locale), and uishop.MallPageDialog; the port has no embedded browser, so ShowWebPage/MoveWebPage/HideWebPage are stubs in platform/UserInterface/PythonApplication.cpp", "evidence": "LC_ALL=C grep ShowWebPage/uiweb/IN_GAME_SHOP_ENABLE over the root .py (the earlier grep ran without LC_ALL=C and missed the CP949 scripts)"} +{"date": "2026-09-30", "unit": "EterLib/JpegFile.cpp", "action": "jpeg_save ADAPTED (stb_image_write), destination managers + unused load/save_to_mem N_A", "evidence": "tests/port/port_screenshot_test.cpp; only CPythonGraphic::SaveJPEG calls jpeg_save"} +{"date": "2026-09-30", "unit": "EterPythonLib/PythonGraphic.cpp", "action": "GenScreenShotTag/SaveJPEG PORTED, SaveScreenShot ADAPTED (next-frame readback via ScreenShotHost)", "evidence": "port.screenshot; desktop grp.SaveScreenShot() -> ~/Documents/METIN2/0930_124051.jpg, colours checked"} diff --git a/audit/port-map/EterLib/JpegFile.cpp.json b/audit/port-map/EterLib/JpegFile.cpp.json new file mode 100644 index 00000000..970d1801 --- /dev/null +++ b/audit/port-map/EterLib/JpegFile.cpp.json @@ -0,0 +1,78 @@ +{ + "reference": "EterLib/JpegFile.cpp", + "reference_sha256": "5e4417d5dfe77c4921e22b3196ca894a40034e41a19936fc49b509ad2c19b98a", + "priority": "P4", + "contracts": [], + "functions": { + "file_init_destination": { + "status": "N_A", + "note": "libjpeg stdio destination manager behind jpeg_save; stb_image_write writes through its own callback (see jpeg_save ADAPTED)." + }, + "file_empty_output_buffer": { + "status": "N_A", + "note": "libjpeg stdio destination manager behind jpeg_save; stb_image_write writes through its own callback (see jpeg_save ADAPTED)." + }, + "file_term_destination": { + "status": "N_A", + "note": "libjpeg stdio destination manager behind jpeg_save; stb_image_write writes through its own callback (see jpeg_save ADAPTED)." + }, + "mem_init_destination": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "mem_empty_output_buffer": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "mem_term_destination": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "jpeg_save": { + "status": "ADAPTED", + "impl": [ + "src/platform/EterLib/JpegFile.cpp:jpeg_save" + ], + "test": [ + "tests/port/port_screenshot_test.cpp" + ], + "note": "40250 encodes with libjpeg-9a (stdio destination, JCS_RGB, jpeg_set_defaults + jpeg_set_quality(quality)); the port encodes the same top-down RGB rows at the same quality with stb_image_write into a baseline JFIF file. Invariant: returns 1 with a complete JPEG (SOI..EOI) at `filename`, 0 when the file cannot be opened. '\\\\' in the path is taken as a separator like the Win32 CRT fopen." + }, + "jpeg_save_to_file": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "jpeg_save_to_mem": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "mem_init_source": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "mem_fill_input_buffer": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "mem_skip_input_data": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "mem_resync_to_restart": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "mem_term_source": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "jpeg_load_from_mem": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + }, + "jpeg_load": { + "status": "N_A", + "note": "No caller in 40250 (LC_ALL=C grep -a over ClientVS22/source and the root .py: only CPythonGraphic::SaveJPEG calls jpeg_save); left undefined." + } + } +} diff --git a/audit/port-map/EterPythonLib/PythonGraphic.cpp.json b/audit/port-map/EterPythonLib/PythonGraphic.cpp.json index 437701b5..4e096059 100644 --- a/audit/port-map/EterPythonLib/PythonGraphic.cpp.json +++ b/audit/port-map/EterPythonLib/PythonGraphic.cpp.json @@ -63,13 +63,32 @@ "note": "Verbatim 40250. The recording device reports no D3DCAPS2_FULLSCREENGAMMA (windowed output, where D3D8 ignores the ramp as well), so it returns at the caps check; IDirect3DDevice8::SetGammaRamp added to the D3D8 shim." }, "GenScreenShotTag": { - "status": "TODO" + "status": "PORTED", + "impl": [ + "src/platform/EterPythonLib/PythonGraphic.cpp:GenScreenShotTag" + ], + "note": "Verbatim 40250; only reached when g_isScreenShotKey (false, never set)." }, "CPythonGraphic::SaveJPEG": { - "status": "TODO" + "status": "PORTED", + "impl": [ + "src/platform/EterPythonLib/PythonGraphic.cpp:CPythonGraphic::SaveJPEG" + ], + "test": [ + "tests/port/port_screenshot_test.cpp" + ] }, "CPythonGraphic::SaveScreenShot": { - "status": "TODO" + "status": "ADAPTED", + "impl": [ + "src/platform/EterPythonLib/PythonGraphic.cpp:CPythonGraphic::SaveScreenShot", + "src/platform/EterPythonLib/PythonGraphic.cpp:ScreenShotHost::WriteScreenShot", + "src/host/live_client.cpp" + ], + "test": [ + "tests/port/port_screenshot_test.cpp" + ], + "note": "40250 locks the D3D8 back buffer and writes the JPEG before returning. The script thread and the SDL host alternate, so the call queues a readback of the next presented frame (ScreenShotHost::SetRequest -> VulkanWindow::request_frame) and returns true; ScreenShotHost::WriteScreenShot runs the rest verbatim one frame later (X8R8G8B8 row loop, SaveJPEG, TraceError on failure, g_isScreenShotKey EXIF block). No host -> TraceError + false (40250's GetBackBuffer failure). Only the X8R8G8B8 branch exists: the swapchain is read back as BGRA. Checked on desktop: grp.SaveScreenShot() wrote ~/Documents/METIN2/.jpg with correct colours (SHGetSpecialFolderPath shim = $HOME/Documents, else cwd)." }, "CPythonGraphic::PushState": { "status": "PORTED", diff --git a/src/host/live_client.cpp b/src/host/live_client.cpp index 0341c885..40e055be 100644 --- a/src/host/live_client.cpp +++ b/src/host/live_client.cpp @@ -13,6 +13,7 @@ #include "platform/PackBackend.h" #include "platform/ScriptLib/PythonBoot.h" +#include "platform/EterPythonLib/ScreenShotHost.h" #include "platform/UserInterface/ServerClock.h" #include "platform/EterBase/TraceErrorObserver.h" #include "platform/EterBase/FrameRateMode.h" @@ -188,6 +189,11 @@ int run_live_client( SDL_Log("live stage: PythonBoot started"); #endif + ScreenShotHost::SetRequest([&renderer](const std::string& path) { + return renderer.request_frame([path](const std::uint8_t* bgra, std::uint32_t width, std::uint32_t height) { + ScreenShotHost::WriteScreenShot(path.c_str(), bgra, width, height, width * 4); + }); + }); SetPlatformServerTime(123456789); PythonBoot::SetUISafeInset(renderer.ui_safe_inset()); PythonBoot::SetUISize(static_cast(renderer.logical_width()), static_cast(renderer.logical_height())); @@ -505,6 +511,7 @@ int run_live_client( const auto wall_ms = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); print_summary(renderer, completed, wall_ms, update_ms, std::move(frame_ms)); + ScreenShotHost::SetRequest(nullptr); PythonBoot::Stop(); #ifdef __ANDROID__ SDL_Log("live stage: PythonBoot stopped"); diff --git a/src/host/sdl_window.cpp b/src/host/sdl_window.cpp index 2c333380..7bd7d28a 100644 --- a/src/host/sdl_window.cpp +++ b/src/host/sdl_window.cpp @@ -139,6 +139,12 @@ void VulkanWindow::request_screenshot(std::string path, std::uint64_t frame) { screenshot_frame_ = frame; } +bool VulkanWindow::request_frame(FrameSink sink) { + if (!swapchain_readable_) return false; + frame_sinks_.push_back(std::move(sink)); + return true; +} + std::uint32_t VulkanWindow::logical_width() const { int w = 0, h = 0; if (window_) SDL_GetWindowSize(window_, &w, &h); diff --git a/src/host/vk_device.cpp b/src/host/vk_device.cpp index d08f49ad..25008ba3 100644 --- a/src/host/vk_device.cpp +++ b/src/host/vk_device.cpp @@ -120,6 +120,7 @@ void VulkanWindow::create_swapchain() { info.imageFormat = format.format; info.imageColorSpace = format.colorSpace; info.imageExtent = extent_; info.imageArrayLayers = 1; info.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | (capabilities.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_SRC_BIT); + swapchain_readable_ = (info.imageUsage & VK_IMAGE_USAGE_TRANSFER_SRC_BIT) != 0; info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; // Android surfaces can report a quarter-turn transform even when SDL's window is // already landscape. Rendering in SDL window coordinates and requesting that transform diff --git a/src/host/vk_frame.cpp b/src/host/vk_frame.cpp index aeb84b2f..bfebbb58 100644 --- a/src/host/vk_frame.cpp +++ b/src/host/vk_frame.cpp @@ -493,7 +493,9 @@ void VulkanWindow::record_passes(std::uint32_t image_index, const FrameDraws& fr VulkanWindow::Readback VulkanWindow::record_readbacks(std::uint32_t image_index) { Readback readback; - readback.screenshot = !screenshot_path_.empty() && frame_number_ == screenshot_frame_; + readback.bmp = !screenshot_path_.empty() && frame_number_ == screenshot_frame_; + readback.sinks.swap(frame_sinks_); + readback.screenshot = readback.bmp || !readback.sinks.empty(); if (readback.screenshot) { const VkDeviceSize bytes = VkDeviceSize(extent_.width) * extent_.height * 4; VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; @@ -530,7 +532,7 @@ VulkanWindow::Readback VulkanWindow::record_readbacks(std::uint32_t image_index) } // Debug: MT_DUMP_RT=prefix writes each render target of the screenshot frame to _.pgm. const char* dump_rt = std::getenv("MT_DUMP_RT"); - if (readback.screenshot && dump_rt && *dump_rt) { + if (readback.bmp && dump_rt && *dump_rt) { readback.dump_rt = dump_rt; const std::uint32_t texel = offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4; for (auto& entry : render_targets_) { @@ -578,7 +580,15 @@ void VulkanWindow::finish_readbacks(Readback& readback) { check(vkWaitForFences(device_, 1, &f_->fence, VK_TRUE, UINT64_MAX), "vkWaitForFences readback"); void* mapped = nullptr; check(vkMapMemory(device_, readback.memory, 0, VK_WHOLE_SIZE, 0, &mapped), "vkMapMemory readback"); - write_bmp(screenshot_path_, static_cast(mapped)); + if (readback.bmp) write_bmp(screenshot_path_, static_cast(mapped)); + if (!readback.sinks.empty()) { + const std::uint32_t w = extent_.width, h = extent_.height; + std::vector bgra(static_cast(mapped), + static_cast(mapped) + std::size_t(w) * h * 4); + if (swapchain_format_ == VK_FORMAT_R8G8B8A8_UNORM || swapchain_format_ == VK_FORMAT_R8G8B8A8_SRGB) + for (std::size_t i = 0; i < bgra.size(); i += 4) std::swap(bgra[i], bgra[i + 2]); + for (auto& sink : readback.sinks) sink(bgra.data(), w, h); + } vkUnmapMemory(device_, readback.memory); const std::uint32_t texel = offscreen_format_ == VK_FORMAT_R5G6B5_UNORM_PACK16 ? 2 : 4; for (std::size_t n = 0; n < readback.rt_dumps.size(); ++n) { diff --git a/src/host/vulkan_window.h b/src/host/vulkan_window.h index 6a7edca8..28e835c5 100644 --- a/src/host/vulkan_window.h +++ b/src/host/vulkan_window.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -133,6 +134,10 @@ public: double input_idle_seconds() const; void request_screenshot(std::string path, std::uint64_t frame); + // Hands the next presented frame to `sink` as top-down BGRA rows (grp.SaveScreenShot). False when the + // swapchain images cannot be copied from. + using FrameSink = std::function; + bool request_frame(FrameSink sink); std::uint64_t frame_number() const { return frame_number_; } int msaa_samples() const { return int(samples_); } @@ -215,7 +220,9 @@ private: // Host copies of the swapchain image (--screenshot-out) and, with MT_DUMP_RT, of every render target. struct Readback { struct RtDump { VkBuffer buffer; VkDeviceMemory memory; std::uint32_t w, h; }; - bool screenshot = false; + bool screenshot = false; // the swapchain image is copied (a BMP or a frame sink wants it) + bool bmp = false; // --screenshot-out + std::vector sinks; VkBuffer buffer = VK_NULL_HANDLE; VkDeviceMemory memory = VK_NULL_HANDLE; const char* dump_rt = nullptr; @@ -398,6 +405,8 @@ private: // --screenshot-out: read back the swapchain image of one frame into a BMP file. std::string screenshot_path_; std::uint64_t screenshot_frame_ = 0; + std::vector frame_sinks_; // request_frame, served by the next frame's readback + bool swapchain_readable_ = false; // the swapchain images have TRANSFER_SRC usage VkImage depth_image_ = VK_NULL_HANDLE; VkDeviceMemory depth_memory_ = VK_NULL_HANDLE; VkImageView depth_view_ = VK_NULL_HANDLE; diff --git a/src/platform/EterLib/JpegFile.cpp b/src/platform/EterLib/JpegFile.cpp new file mode 100644 index 00000000..d0fae857 --- /dev/null +++ b/src/platform/EterLib/JpegFile.cpp @@ -0,0 +1,37 @@ +// Platform implementation of EterLib/JpegFile.h. 40250 writes it with libjpeg-9a (JpegFile.cpp: a stdio +// destination manager, JCS_RGB, jpeg_set_defaults + jpeg_set_quality); here stb_image_write encodes the +// same input (top-down RGB rows, 3 bytes per pixel) at the same quality into a baseline JFIF file. +// +// Only jpeg_save has a caller in 40250 (CPythonGraphic::SaveJPEG, the screenshot key); the other +// declarations of JpegFile.h stay undefined. +#include "EterLib/StdAfx.h" +#include "EterLib/JpegFile.h" + +#include +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#define STB_IMAGE_WRITE_STATIC +#define STBI_WRITE_NO_STDIO +#include "../third_party/stb_image_write.h" + +namespace +{ +void write_to_file(void* context, void* data, int size) +{ + fwrite(data, 1, size_t(size), static_cast(context)); +} +} + +int jpeg_save(unsigned char*data_d, int width, int height, int quality, const char*filename) +{ + // The Win32 CRT takes '\\' as a separator (grp.SaveScreenShot builds "\\METIN2\\