From ab3e926058d8ede1412502aa10001284a2aede41 Mon Sep 17 00:00:00 2001 From: Doston Abdullayev Date: Sun, 11 Oct 2026 10:02:26 +0500 Subject: [PATCH 1/4] feat(relinker): write the output of a Windows executable without standard handles to anyps5.log A --windows-gui build started from Explorer, or any Windows executable started without a standard output or error handle, lost everything it printed: the entry stub's messages, the libraries' stdout and stderr, uncaught exception messages and crash reports. Before it enters its directory or loads a library, the entry stub now checks the standard output and error handles, and gives each missing one anyps5.log beside the executable. The C runtime of the libraries it then loads adopts the file. Executables started with handles are unchanged, and one that cannot create the file starts as before. Co-Authored-By: Claude Opus 5.5 --- .../src/windows/WindowsEntryStubBuilder.cpp | 47 +++++++++++++++++++ .../src/windows/WindowsImportBuilder.cpp | 2 +- .../tests/test_windows_import_modules.py | 9 ++-- docs/user/USAGE.md | 2 + 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/core/relinker/elfpatcher/src/windows/WindowsEntryStubBuilder.cpp b/core/relinker/elfpatcher/src/windows/WindowsEntryStubBuilder.cpp index 83281042f..7512f50c3 100644 --- a/core/relinker/elfpatcher/src/windows/WindowsEntryStubBuilder.cpp +++ b/core/relinker/elfpatcher/src/windows/WindowsEntryStubBuilder.cpp @@ -100,6 +100,9 @@ WindowsEntryStub WindowsEntryStubBuilder::Build(const std::uint32_t dataRva, con const auto argumentCount = reserve(4); const auto wideArguments = reserve(8); const auto shellHandle = reserve(8); + const auto logHandle = reserve(8); + const std::u16string logFileName = u"anyps5.log"; + const auto logName = addWideString(logFileName); const auto shellLibrary = addString("shell32.dll"); const auto parseArguments = addString("CommandLineToArgvW"); const auto handles = reserve(libraries.size() * 8); @@ -325,6 +328,48 @@ WindowsEntryStub WindowsEntryStubBuilder::Build(const std::uint32_t dataRva, con code.PatchBranch(converted, code.GetRva()); }; + const auto logMissingHandle = [&](const std::uint32_t standardHandle) { + const auto units = CheckedRva(logFileName.size() + 1); + code.Emit({0xb9}); + code.U32(standardHandle); + call("GetStdHandle"); + code.Emit({0x48, 0x85, 0xc0}); + const auto missing = code.Branch({0x0f, 0x84}); + code.Emit({0x48, 0x83, 0xf8, 0xff}); + const auto present = code.Branch({0x0f, 0x85}); + code.PatchBranch(missing, code.GetRva()); + code.Rip({0x48, 0x8b, 0x05}, logHandle); + code.Emit({0x48, 0x85, 0xc0}); + const auto opened = code.Branch({0x0f, 0x85}); + code.Emit({0x4c, 0x89, 0xe0, 0x48, 0x29, 0xd8, 0x48, 0x05}); + code.U32(units * 2); + code.Emit({0x48, 0x3d}); + code.U32(PathCapacity * 2); + const auto tooLong = code.Branch({0x0f, 0x87}); + code.Emit({0x4c, 0x89, 0xe7}); + code.Rip({0x48, 0x8d, 0x35}, logName); + code.Emit({0xb9}); + code.U32(units); + code.Emit({0x66, 0xf3, 0xa5}); + code.Rip({0x48, 0x8d, 0x0d}, modulePath); + code.Emit({0xba, 0, 0, 0, 0x40, 0x41, 0xb8, 3, 0, 0, 0, 0x45, 0x31, 0xc9}); + code.Emit({0x48, 0xc7, 0x44, 0x24, 0x20, 2, 0, 0, 0, 0x48, 0xc7, 0x44, 0x24, 0x28, 0x80, 0, 0, 0, 0x48, 0xc7, 0x44, 0x24, 0x30, 0, 0, 0, 0}); + call("CreateFileW"); + code.Emit({0x66, 0x41, 0xc7, 0x04, 0x24, 0x00, 0x00}); + code.Emit({0x48, 0x83, 0xf8, 0xff}); + const auto failed = code.Branch({0x0f, 0x84}); + code.Rip({0x48, 0x89, 0x05}, logHandle); + code.PatchBranch(opened, code.GetRva()); + code.Emit({0xb9}); + code.U32(standardHandle); + code.Emit({0x48, 0x89, 0xc2}); + call("SetStdHandle"); + const auto done = code.GetRva(); + code.PatchBranch(present, done); + code.PatchBranch(tooLong, done); + code.PatchBranch(failed, done); + }; + code.Emit({0x53, 0x55, 0x56, 0x57, 0x41, 0x54, 0x48, 0x83, 0xec, 0x60}); code.Emit({0x31, 0xc9}); code.Rip({0x48, 0x8d, 0x15}, programPath); @@ -351,6 +396,8 @@ WindowsEntryStub WindowsEntryStubBuilder::Build(const std::uint32_t dataRva, con code.Rip({0x0f, 0x85}, findSeparator); code.Emit({0x49, 0x83, 0xc4, 0x02}); code.Emit({0x66, 0x41, 0xc7, 0x04, 0x24, 0x00, 0x00}); + logMissingHandle(0xfffffff5u); + logMissingHandle(0xfffffff4u); code.Rip({0x48, 0x8d, 0x0d}, modulePath); call("SetCurrentDirectoryW"); requireNonzero(directoryError, 0xc000000du); diff --git a/core/relinker/elfpatcher/src/windows/WindowsImportBuilder.cpp b/core/relinker/elfpatcher/src/windows/WindowsImportBuilder.cpp index 30cb39ecd..71ce4f2ba 100644 --- a/core/relinker/elfpatcher/src/windows/WindowsImportBuilder.cpp +++ b/core/relinker/elfpatcher/src/windows/WindowsImportBuilder.cpp @@ -5,7 +5,7 @@ namespace Elfpatcher::Windows { WindowsImports WindowsImportBuilder::Build(const std::uint32_t sectionRva) const { - const std::vector names = {"ExitProcess", "FormatMessageA", "FreeLibrary", "GetCommandLineW", "GetFileAttributesW", "GetLastError", "GetModuleFileNameW", "GetModuleHandleA", "GetModuleHandleW", "GetProcAddress", "GetStdHandle", "GetSystemDirectoryW", "LoadLibraryExA", "LoadLibraryExW", "LocalFree", "MultiByteToWideChar", "RaiseException", "SetCurrentDirectoryW", "VirtualAlloc", "WideCharToMultiByte", "WriteFile", "lstrcatA", "lstrcmpA", "lstrcmpiA", "lstrcpyA", "lstrlenA"}; + const std::vector names = {"CreateFileW", "ExitProcess", "FormatMessageA", "FreeLibrary", "GetCommandLineW", "GetFileAttributesW", "GetLastError", "GetModuleFileNameW", "GetModuleHandleA", "GetModuleHandleW", "GetProcAddress", "GetStdHandle", "GetSystemDirectoryW", "LoadLibraryExA", "LoadLibraryExW", "LocalFree", "MultiByteToWideChar", "RaiseException", "SetCurrentDirectoryW", "SetStdHandle", "VirtualAlloc", "WideCharToMultiByte", "WriteFile", "lstrcatA", "lstrcmpA", "lstrcmpiA", "lstrcpyA", "lstrlenA"}; WindowsImports result{{".idata", sectionRva, SectionRead | SectionWrite | 0x40u, std::vector(40)}, {sectionRva, 40}, {}, {}}; auto& bytes = result.Section.Data; const auto lookupOffset = bytes.size(); diff --git a/core/relinker/relinker/tests/test_windows_import_modules.py b/core/relinker/relinker/tests/test_windows_import_modules.py index 5a03c9fa0..8aef4fdec 100644 --- a/core/relinker/relinker/tests/test_windows_import_modules.py +++ b/core/relinker/relinker/tests/test_windows_import_modules.py @@ -384,19 +384,22 @@ def main(): assert not output.exists() if os.name == 'nt': - for name, owner, symbol, expected in [ - ('missing-symbol-gui', 'a.prx', 'absent#A#B', 0xc0000139), - ('missing-dependency-gui', 'missing.prx', 'shared#A#B', 0xc0000135)]: + for name, owner, symbol, expected, message in [ + ('missing-symbol-gui', 'a.prx', 'absent#A#B', 0xc0000139, 'unresolved ELF import absent'), + ('missing-dependency-gui', 'missing.prx', 'shared#A#B', 0xc0000135, 'Failed to load module')]: result, output = convert(name, owner, symbol, windows_gui=True) assert result.returncode == 0, result.stderr run_status = exit_code_without_standard_handles(output) assert run_status == expected, (name, hex(run_status), hex(expected)) + log = output.parent / 'anyps5.log' + assert log.is_file() and message in log.read_text(errors='replace'), (name, log.exists()) result, output = convert('missing-symbol', 'a.prx', 'absent#A#B') assert result.returncode == 0, result.stderr if os.name == 'nt': run = subprocess.run([str(output)], capture_output=True, text=True, timeout=30) assert run.returncode != 0 and 'unresolved ELF import absent' in run.stderr, (run.returncode, run.stderr) + assert not (output.parent / 'anyps5.log').exists() result, output = convert('wrong-provider', 'c.prx') assert result.returncode == 0, result.stderr if os.name == 'nt': diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 12accb979..2f6b5d8d0 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -119,6 +119,8 @@ python3 tools/pad_input.py /tmp/anyps5-pad release Run the helper in a second terminal. Commands are polled at most every 100 ms during controller reads and add to physical controller input. A press lasts 300 ms by default, starting when the application reads it, and releases automatically; `--hold-ms` accepts 1–5000 ms. Wait for the application to observe a release before sending another press. The file holds only the latest command; rapid commands can replace one another. Commands left over when the application first reads the controller are ignored. Without the environment variable, file input is disabled. +A Windows executable started without a standard output or error handle, such as a `--windows-gui` build started from Explorer, writes that output to `anyps5.log` beside it, replacing the previous run's file. Without write access to that directory it runs without the log. + ### System fonts Games that open the console's system font sets (`sceFontOpenFontSet`) need font files in an `anyps5-fonts/` directory beside the output executable; set `ANYPS5_SYSTEM_FONTS` to use another directory. Files dumped from the console are used under their own names (`SST-Roman.otf`, `SST-Bold.otf`, `SSTJpPro-Regular.otf`, ...). Without them, these openly licensed substitutes are used when present: `NotoSans-{Light,Regular,Medium,Bold}.ttf` and `NotoSans-{LightItalic,Italic,MediumItalic,BoldItalic}.ttf` (Latin and Vietnamese), `NotoSansMono-{Light,Regular,Medium,Bold}.ttf` (typewriter), `NotoSansThai-{Light,Regular,Medium,Bold}.ttf` (Thai) and `NotoSansCJK-{Light,Regular,Medium,Bold}.ttc` (Japanese and Chinese). Without either, opening a system font set fails and the game shows no text in those fonts. From 6effc5dcec1b168fd5c8223ec02e18b491444d50 Mon Sep 17 00:00:00 2001 From: "ChatGPT (local)" Date: Sun, 11 Oct 2026 10:31:08 +0300 Subject: [PATCH 2/4] test(relinker): verify GUI startup log replacement and unavailable log Cover successful GUI stdout logging, replacement of stale log, and startup when log path is a directory. AI-assisted: ChatGPT. --- .../tests/test_windows_import_modules.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/core/relinker/relinker/tests/test_windows_import_modules.py b/core/relinker/relinker/tests/test_windows_import_modules.py index 8aef4fdec..5001508c8 100644 --- a/core/relinker/relinker/tests/test_windows_import_modules.py +++ b/core/relinker/relinker/tests/test_windows_import_modules.py @@ -394,6 +394,24 @@ def main(): log = output.parent / 'anyps5.log' assert log.is_file() and message in log.read_text(errors='replace'), (name, log.exists()) + result, output = convert('gui-success-log', 'a.prx', guest_owner='b.prx', windows_gui=True) + assert result.returncode == 0, result.stderr + log = output.parent / 'anyps5.log' + log.write_text('stale previous run', encoding='utf-8') + run_status = exit_code_without_standard_handles(output) + assert run_status == 11, hex(run_status) + content = log.read_text(errors='replace') + assert 'Transferring control to ELF entry point' in content, content + assert 'stale previous run' not in content, content + + result, output = convert('gui-log-create-denied', 'a.prx', guest_owner='b.prx', windows_gui=True) + assert result.returncode == 0, result.stderr + log = output.parent / 'anyps5.log' + log.mkdir() + run_status = exit_code_without_standard_handles(output) + assert run_status == 11, hex(run_status) + assert log.is_dir() + result, output = convert('missing-symbol', 'a.prx', 'absent#A#B') assert result.returncode == 0, result.stderr if os.name == 'nt': From ae96779914f649b2eeb7dcf24614750abee6e71f Mon Sep 17 00:00:00 2001 From: danzaio Date: Sun, 11 Oct 2026 19:06:09 -0300 Subject: [PATCH 3/4] test(relinker): verify Windows DLL CRT output handles --- core/relinker/CMakeLists.txt | 37 ++- .../elfpatcher/tests/WindowsMsvcrt.def | 4 + .../tests/WindowsMsvcrtStdioHost.cpp | 22 ++ .../elfpatcher/tests/WindowsStdioHost.cpp | 7 + .../tests/test_windows_import_modules.py | 219 ++++++++++++++++++ 5 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 core/relinker/elfpatcher/tests/WindowsMsvcrt.def create mode 100644 core/relinker/elfpatcher/tests/WindowsMsvcrtStdioHost.cpp create mode 100644 core/relinker/elfpatcher/tests/WindowsStdioHost.cpp diff --git a/core/relinker/CMakeLists.txt b/core/relinker/CMakeLists.txt index 4e8e1a4ad..f3da18aac 100644 --- a/core/relinker/CMakeLists.txt +++ b/core/relinker/CMakeLists.txt @@ -428,8 +428,43 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_guest_module_identity.py $) add_test(NAME guest_needed_nested_modules COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_guest_needed_nested_modules.py $) + set(windows_stdio_host_arguments) + if(WIN32) + if(MINGW) + add_library(windows_stdio_host_ucrt SHARED elfpatcher/tests/WindowsStdioHost.cpp) + target_compile_options(windows_stdio_host_ucrt PRIVATE -mcrtdll=ucrt) + target_link_options(windows_stdio_host_ucrt PRIVATE -mcrtdll=ucrt -static-libgcc -static-libstdc++) + set_target_properties(windows_stdio_host_ucrt PROPERTIES PREFIX "" WINDOWS_EXPORT_ALL_SYMBOLS ON) + list(APPEND windows_stdio_host_arguments $) + + get_filename_component(testCompilerDirectory "${CMAKE_CXX_COMPILER}" DIRECTORY) + set(mingwDlltoolExecutable "${testCompilerDirectory}/dlltool.exe") + if(NOT EXISTS "${mingwDlltoolExecutable}") + message(FATAL_ERROR "Cannot locate MinGW DLL tool: ${mingwDlltoolExecutable}") + endif() + set(msvcrtImportLibrary "${CMAKE_CURRENT_BINARY_DIR}/windows_stdio_msvcrt_import.a") + add_custom_command(OUTPUT "${msvcrtImportLibrary}" + COMMAND "${mingwDlltoolExecutable}" -d "${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/tests/WindowsMsvcrt.def" + -l "${msvcrtImportLibrary}" -D msvcrt.dll + DEPENDS elfpatcher/tests/WindowsMsvcrt.def + VERBATIM) + add_custom_target(windows_stdio_msvcrt_import DEPENDS "${msvcrtImportLibrary}") + add_library(windows_stdio_host_msvcrt SHARED elfpatcher/tests/WindowsMsvcrtStdioHost.cpp) + add_dependencies(windows_stdio_host_msvcrt windows_stdio_msvcrt_import) + target_link_libraries(windows_stdio_host_msvcrt PRIVATE "${msvcrtImportLibrary}") + target_compile_options(windows_stdio_host_msvcrt PRIVATE -fno-builtin) + target_link_options(windows_stdio_host_msvcrt PRIVATE -nostdlib -Wl,--no-undefined -Wl,--entry,0) + set_target_properties(windows_stdio_host_msvcrt PROPERTIES PREFIX "" WINDOWS_EXPORT_ALL_SYMBOLS ON) + list(APPEND windows_stdio_host_arguments $) + else() + add_library(windows_stdio_host SHARED elfpatcher/tests/WindowsStdioHost.cpp) + set_target_properties(windows_stdio_host PROPERTIES PREFIX "" WINDOWS_EXPORT_ALL_SYMBOLS ON) + list(APPEND windows_stdio_host_arguments $) + endif() + endif() add_test(NAME windows_import_modules COMMAND ${Python3_EXECUTABLE} - ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_windows_import_modules.py $) + ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_windows_import_modules.py + $ ${windows_stdio_host_arguments}) add_test(NAME elf_header COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_elf_header.py $) add_test(NAME input_magic COMMAND ${Python3_EXECUTABLE} diff --git a/core/relinker/elfpatcher/tests/WindowsMsvcrt.def b/core/relinker/elfpatcher/tests/WindowsMsvcrt.def new file mode 100644 index 000000000..c163db8f4 --- /dev/null +++ b/core/relinker/elfpatcher/tests/WindowsMsvcrt.def @@ -0,0 +1,4 @@ +LIBRARY msvcrt.dll +EXPORTS + __iob_func + fputs diff --git a/core/relinker/elfpatcher/tests/WindowsMsvcrtStdioHost.cpp b/core/relinker/elfpatcher/tests/WindowsMsvcrtStdioHost.cpp new file mode 100644 index 000000000..4c370e092 --- /dev/null +++ b/core/relinker/elfpatcher/tests/WindowsMsvcrtStdioHost.cpp @@ -0,0 +1,22 @@ +struct _iobuf { + char* ptr; + int count; + char* base; + int flags; + int file; + int charbuf; + int bufferSize; + char* tempName; +}; + +static_assert(sizeof(_iobuf) == 48); + +extern "C" _iobuf* __cdecl __iob_func(); +extern "C" int __cdecl fputs(const char*, _iobuf*); + +extern "C" int shared() { + auto* streams = __iob_func(); + fputs("Windows DLL stdout probe\n", streams + 1); + fputs("Windows DLL stderr probe\n", streams + 2); + return 11; +} diff --git a/core/relinker/elfpatcher/tests/WindowsStdioHost.cpp b/core/relinker/elfpatcher/tests/WindowsStdioHost.cpp new file mode 100644 index 000000000..72a29373f --- /dev/null +++ b/core/relinker/elfpatcher/tests/WindowsStdioHost.cpp @@ -0,0 +1,7 @@ +#include + +extern "C" int shared() { + std::fputs("Windows DLL stdout probe\n", stdout); + std::fputs("Windows DLL stderr probe\n", stderr); + return 11; +} diff --git a/core/relinker/relinker/tests/test_windows_import_modules.py b/core/relinker/relinker/tests/test_windows_import_modules.py index 5001508c8..f23f2abda 100644 --- a/core/relinker/relinker/tests/test_windows_import_modules.py +++ b/core/relinker/relinker/tests/test_windows_import_modules.py @@ -67,6 +67,41 @@ def check_native_load_paths(output, libraries): assert code[calls[0] - 11:calls[0]] == b'\x48\x89\xf1\x31\xd2\x41\xb8\x00\x11\x00\x00', library +def imported_symbols_by_dll(path): + pe = path.read_bytes() + header = struct.unpack_from(' Date: Mon, 12 Oct 2026 00:18:47 -0300 Subject: [PATCH 4/4] docs(user): relocate Windows standard handles logging note to Windows runtime layout section --- docs/user/USAGE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 2f6b5d8d0..d7ded955a 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -106,6 +106,8 @@ Windows PowerShell: .\app.exe ``` +A Windows executable started without a standard output or error handle, such as a `--windows-gui` build started from Explorer, writes that output to `anyps5.log` beside it, replacing the previous run's file. Without write access to that directory it runs without the log. + ### Controller input for test runs Set `APS5_PAD_INPUT_FILE` to an absolute path before launching the application to enable controller pulses from a local command file. Once the application is reading controller input, send a button with the helper from the repository root: @@ -119,8 +121,6 @@ python3 tools/pad_input.py /tmp/anyps5-pad release Run the helper in a second terminal. Commands are polled at most every 100 ms during controller reads and add to physical controller input. A press lasts 300 ms by default, starting when the application reads it, and releases automatically; `--hold-ms` accepts 1–5000 ms. Wait for the application to observe a release before sending another press. The file holds only the latest command; rapid commands can replace one another. Commands left over when the application first reads the controller are ignored. Without the environment variable, file input is disabled. -A Windows executable started without a standard output or error handle, such as a `--windows-gui` build started from Explorer, writes that output to `anyps5.log` beside it, replacing the previous run's file. Without write access to that directory it runs without the log. - ### System fonts Games that open the console's system font sets (`sceFontOpenFontSet`) need font files in an `anyps5-fonts/` directory beside the output executable; set `ANYPS5_SYSTEM_FONTS` to use another directory. Files dumped from the console are used under their own names (`SST-Roman.otf`, `SST-Bold.otf`, `SSTJpPro-Regular.otf`, ...). Without them, these openly licensed substitutes are used when present: `NotoSans-{Light,Regular,Medium,Bold}.ttf` and `NotoSans-{LightItalic,Italic,MediumItalic,BoldItalic}.ttf` (Latin and Vietnamese), `NotoSansMono-{Light,Regular,Medium,Bold}.ttf` (typewriter), `NotoSansThai-{Light,Regular,Medium,Bold}.ttf` (Thai) and `NotoSansCJK-{Light,Regular,Medium,Bold}.ttc` (Japanese and Chinese). Without either, opening a system font set fails and the game shows no text in those fonts.