diff --git a/RELEASE_README.md b/RELEASE_README.md index cc33853..c44e530 100644 --- a/RELEASE_README.md +++ b/RELEASE_README.md @@ -1,17 +1,25 @@ # PristonTale EU for Linux -Version 0.5.2 installs, updates, repairs, and launches PristonTale EU through a +Version 0.6.0 installs, updates, repairs, and launches PristonTale EU through a native Linux dashboard. Game files come directly from the publisher and are not included in this archive. ## Install -Keep the archive and checksum file together, then run: +Keep the archive, detached signature, checksum, and +`launcher-update-public.pem` together. First confirm the public-key fingerprint +through an independent trusted channel; the expected DER SHA-256 fingerprint is +`3ae6e8dc483b93796b32e3ca270c367d247caf8d047eb796826c8e558a4a9e34`. +Then run: ```text -sha256sum -c pristontale-eu-linux-installer-v0-5-2.tar.gz.sha256 -tar -xzf pristontale-eu-linux-installer-v0-5-2.tar.gz -cd pristontale-eu-linux-installer-v0-5-2 +openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum +openssl dgst -sha256 -verify launcher-update-public.pem \ + -signature pristontale-eu-linux-installer-v0-6-0.tar.gz.sig \ + pristontale-eu-linux-installer-v0-6-0.tar.gz +sha256sum -c pristontale-eu-linux-installer-v0-6-0.tar.gz.sha256 +tar -xzf pristontale-eu-linux-installer-v0-6-0.tar.gz +cd pristontale-eu-linux-installer-v0-6-0 ./install.sh ``` @@ -37,11 +45,6 @@ pristontale-eu gui The main launcher button automatically shows **Install**, **Update**, or **Play** according to the managed game's state. -Version 0.5.0 is the self-update bootstrap. Future signed launcher releases are -checked in the background and appear as an **Update Launcher** action. Launcher -updates are staged separately from game files and retain the previous version -for rollback. - ## Commands ```text @@ -49,16 +52,22 @@ pristontale-eu --help Show all commands and options pristontale-eu gui Open the native dashboard pristontale-eu doctor Check required host components pristontale-eu login-diagnostics Check the login route without credentials +pristontale-eu login-diagnostics --runtime proton7 + Compare the direct-Proton prefix read-only +pristontale-eu login-trace Retired; refuses without starting the game pristontale-eu update Install or update official files pristontale-eu update --repair Verify and repair every game file pristontale-eu update --dry-run Preview an update pristontale-eu import-game "/path/to/game" Import an existing installation pristontale-eu prepare Build the safe runtime view -pristontale-eu play Launch through Gamescope/Proton -pristontale-eu play --no-box Launch without Gamescope +pristontale-eu play Launch through the managed Proton window +pristontale-eu play --no-box Launch without X11 window styling +pristontale-eu play --no-world-fix Compatibility alias for the pristine launch pristontale-eu settings Show current settings -pristontale-eu settings --init Write default settings +pristontale-eu settings --init Create/migrate settings without resetting choices +pristontale-eu settings --reset Explicitly restore every launcher default pristontale-eu launcher-update --check Check the signed launcher channel +pristontale-eu launcher-update --verify Validate the full release without installing it pristontale-eu launcher-update Install and activate an update pristontale-eu launcher-update --rollback Restore the previous launcher pristontale-eu sync Sync without import discovery @@ -66,7 +75,6 @@ pristontale-eu wine-update Run the legacy WPF updater pristontale-eu fetch Fetch the publisher launcher pristontale-eu setup Prepare its Wine environment pristontale-eu cache-compatibility PATH Cache a verified compatibility pair -pristontale-eu cache-runtime-patch PATH Cache a verified runtime patch ``` Use `./install.sh --dry-run` to preview dependency installation or diff --git a/dist/pristontale-eu-linux-installer-v0-5-2.tar.gz.sha256 b/dist/pristontale-eu-linux-installer-v0-5-2.tar.gz.sha256 deleted file mode 100644 index e5b70d6..0000000 --- a/dist/pristontale-eu-linux-installer-v0-5-2.tar.gz.sha256 +++ /dev/null @@ -1 +0,0 @@ -cd0314c9fba1779dac5bbe24190c174941dbed89c371b68808fefa37de7750bf pristontale-eu-linux-installer-v0-5-2.tar.gz diff --git a/dist/pristontale-eu-linux-installer-v0-5-2.zip b/dist/pristontale-eu-linux-installer-v0-5-2.zip deleted file mode 100644 index 96e7ccd..0000000 Binary files a/dist/pristontale-eu-linux-installer-v0-5-2.zip and /dev/null differ diff --git a/dist/pristontale-eu-linux-update-v0.5.2.tar.gz b/dist/pristontale-eu-linux-update-v0.5.2.tar.gz deleted file mode 100644 index c29e0d9..0000000 Binary files a/dist/pristontale-eu-linux-update-v0.5.2.tar.gz and /dev/null differ diff --git a/dist/pristontale-eu-linux-update-v0.5.2.tar.gz.sha256 b/dist/pristontale-eu-linux-update-v0.5.2.tar.gz.sha256 deleted file mode 100644 index aa441a9..0000000 --- a/dist/pristontale-eu-linux-update-v0.5.2.tar.gz.sha256 +++ /dev/null @@ -1 +0,0 @@ -e391accd5448f3239aeda2561e5faf2eb17b5aa5c61837da609f456815398002 pristontale-eu-linux-update-v0.5.2.tar.gz diff --git a/dist/stable.json b/dist/stable.json deleted file mode 100644 index 1eb95e9..0000000 --- a/dist/stable.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "archive_sha256": "e391accd5448f3239aeda2561e5faf2eb17b5aa5c61837da609f456815398002", - "archive_size": 71737, - "archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.5.2/pristontale-eu-linux-update-v0.5.2.tar.gz", - "channel": "stable", - "minimum_updater_version": "0.5.0", - "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.5.2", - "published_at": "2026-08-27T19:01:33Z", - "schema": 1, - "version": "0.5.2" -} diff --git a/dist/stable.json.sig b/dist/stable.json.sig deleted file mode 100644 index ed9fc16..0000000 Binary files a/dist/stable.json.sig and /dev/null differ diff --git a/install.sh b/install.sh index 7c53ac0..827af45 100755 --- a/install.sh +++ b/install.sh @@ -202,7 +202,15 @@ install -d -m 0755 \ "${STAGING_RELEASE}/ptlinux" \ "${STAGING_RELEASE}/ptlinux/native" \ "${STAGING_RELEASE}/ptdiag" -install -m 0644 "${SOURCE_ROOT}/ptlinux/"*.py "${STAGING_RELEASE}/ptlinux/" +for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"*.py +do + case "${SOURCE_MODULE##*/}" in + desktop_entry.py|launcher_wrapper.py) + continue + ;; + esac + install -m 0644 "$SOURCE_MODULE" "${STAGING_RELEASE}/ptlinux/" +done install -m 0644 \ "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ "${STAGING_RELEASE}/ptlinux/launcher-update-public.pem" @@ -292,7 +300,7 @@ CURRENT_LINK="${APP_ROOT}/.current.$$.tmp" ln -s -- "releases/${VERSION}" "$CURRENT_LINK" mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current" -"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/launcher_wrapper.py" \ +"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ "${SOURCE_ROOT}/packaging/pristontale-eu" \ "$APP_ROOT" \ "$INSTALL_DIRECTORY" \ @@ -300,7 +308,7 @@ mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current" install -m 0644 \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ "$ICON_PATH" -"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/desktop_entry.py" \ +"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ "${BIN_HOME}/pristontale-eu" \ "$ICON_PATH" \ diff --git a/native/pt_world_fix.c b/native/pt_world_fix.c deleted file mode 100644 index 62ccd87..0000000 --- a/native/pt_world_fix.c +++ /dev/null @@ -1,1179 +0,0 @@ -#define WIN32_LEAN_AND_MEAN -#include -#include -#include -#include -#include -#include -#include - - -#define GAME_DLL_MAP_ENTRY_RVA 0x007c0b40u -#define GAME_DLL_LOGIN_RETURN_RVA 0x0079f157u -#define GAME_DLL_CODE_CAVE_RVA 0x01195ce0u -#define GAME_DLL_LSTRCMPA_IAT_RVA 0x01196280u -#define GAME_DLL_VIRTUAL_PROTECT_IAT_RVA 0x01196108u -#define GAME_DLL_FLUSH_INSTRUCTION_CACHE_IAT_RVA 0x01196140u -#define GAME_DLL_GET_CURRENT_PROCESS_IAT_RVA 0x01196344u -#define GAME_DLL_MINIMUM_IMAGE_SIZE 0x01196348u - -#define REMOTE_ALLOCATION_SIZE 4096u -#define REMOTE_RECLAIM_OFFSET 0x0600u -#define REMOTE_MAP_JUMP_OFFSET 0x0680u -#define REMOTE_STATE_OFFSET 0x0700u -#define REMOTE_OLD_CAVE_PROTECT_OFFSET 0x0704u -#define REMOTE_OLD_ENTRY_PROTECT_OFFSET 0x0708u -#define REMOTE_TEMP_PROTECT_OFFSET 0x070cu - -#define WORLD_FIX_WAIT_MILLISECONDS 60000u - - -static const BYTE MAP_ENTRY_ORIGINAL[] = {0x55, 0x8b, 0xec, 0x6a, 0xff}; -static const BYTE LOGIN_COMPARE_ORIGINAL[] = { - 0x85, 0xc0, 0x0f, 0x85, 0x6b, 0x01, 0x00, 0x00, -}; -static const BYTE MAP_ENTRY_JUMP[] = {0xe9, 0x9b, 0x51, 0x9d, 0x00}; - -/* - * Exact 97-byte entity-surface reclaim routine verified for V21.75.83. - * Its relative calls and return jump are valid at GAME_DLL_CODE_CAVE_RVA. - */ -static const BYTE PROVEN_RECLAIM_HOOK[] = { - 0x9c, 0x60, 0xe8, 0x49, 0x01, 0x36, 0xff, 0x85, - 0xc0, 0x74, 0x4a, 0x89, 0xc3, 0x8d, 0x70, 0x08, - 0x8d, 0xbe, 0x50, 0x5d, 0x01, 0x00, 0x39, 0xfe, - 0x74, 0x3b, 0x80, 0xbe, 0x58, 0x03, 0x00, 0x00, - 0x00, 0x74, 0x2a, 0x8b, 0x06, 0x8b, 0x4c, 0x24, - 0x28, 0x85, 0xc9, 0x74, 0x08, 0x3b, 0x81, 0xd4, - 0x1f, 0x00, 0x00, 0x74, 0x18, 0x8b, 0x4c, 0x24, - 0x2c, 0x85, 0xc9, 0x74, 0x08, 0x3b, 0x81, 0xd4, - 0x1f, 0x00, 0x00, 0x74, 0x08, 0x50, 0x89, 0xd9, - 0xe8, 0xa3, 0x20, 0x36, 0xff, 0x81, 0xc6, 0x50, - 0x04, 0x00, 0x00, 0xeb, 0xc1, 0x61, 0x9d, 0x55, - 0x89, 0xe5, 0x6a, 0xff, 0xe9, 0x04, 0xae, 0x62, - 0xff, -}; - - -typedef struct CodeBuffer -{ - BYTE *data; - SIZE_T capacity; - SIZE_T length; - BOOL valid; -} CodeBuffer; - - -typedef struct WorldFixInstallation -{ - DWORD remote_state; - LPVOID remote_allocation; -} WorldFixInstallation; - - -typedef struct StatusLog -{ - HANDLE handle; -} StatusLog; - - -static void initialize_status_log(StatusLog *status) -{ - status->handle = INVALID_HANDLE_VALUE; -} - - -static BOOL status_path_is_allowed(const wchar_t *path) -{ - static const wchar_t prefix[] = L"save\\launcher\\ptworld-"; - static const wchar_t suffix[] = L".jsonl"; - SIZE_T path_length; - SIZE_T prefix_length = (sizeof(prefix) / sizeof(prefix[0])) - 1; - SIZE_T suffix_length = (sizeof(suffix) / sizeof(suffix[0])) - 1; - - if (path == NULL) - { - return FALSE; - } - path_length = wcslen(path); - if ( - path_length <= prefix_length + suffix_length - || path_length >= MAX_PATH - || wcsncmp(path, prefix, prefix_length) != 0 - || wcscmp(path + path_length - suffix_length, suffix) != 0 - ) - { - return FALSE; - } - return wcschr(path, L'/') == NULL - && wcschr(path, L':') == NULL - && wcsstr(path, L"..") == NULL; -} - - -static BOOL open_status_log(StatusLog *status, const wchar_t *path) -{ - if (!status_path_is_allowed(path)) - { - return FALSE; - } - status->handle = CreateFileW( - path, - GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - NULL, - CREATE_NEW, - FILE_ATTRIBUTE_NORMAL, - NULL - ); - return status->handle != INVALID_HANDLE_VALUE; -} - - -static void write_status_event( - StatusLog *status, - const char *event, - const char *detail_format, - ... -) -{ - char details[512]; - char line[768]; - int line_length; - DWORD written = 0; - va_list arguments; - - if (status->handle == INVALID_HANDLE_VALUE) - { - return; - } - details[0] = '\0'; - if (detail_format != NULL) - { - va_start(arguments, detail_format); - vsnprintf(details, sizeof(details), detail_format, arguments); - va_end(arguments); - details[sizeof(details) - 1] = '\0'; - } - line_length = snprintf( - line, - sizeof(line), - "{\"protocol\":1,\"event\":\"%s\",\"tick_ms\":%llu%s}\r\n", - event, - (unsigned long long)GetTickCount64(), - details - ); - if (line_length <= 0 || (SIZE_T)line_length >= sizeof(line)) - { - return; - } - WriteFile(status->handle, line, (DWORD)line_length, &written, NULL); - FlushFileBuffers(status->handle); -} - - -static void close_status_log(StatusLog *status) -{ - if (status->handle != INVALID_HANDLE_VALUE) - { - CloseHandle(status->handle); - status->handle = INVALID_HANDLE_VALUE; - } -} - - -static DWORD address_from_rva(DWORD base, DWORD rva) -{ - return base + rva; -} - - -static DWORD image_dword(const BYTE *image, SIZE_T offset) -{ - DWORD value; - memcpy(&value, image + offset, sizeof(value)); - return value; -} - - -static BOOL read_exact(HANDLE process, DWORD address, void *buffer, SIZE_T size) -{ - SIZE_T read_size = 0; - return ReadProcessMemory( - process, - (LPCVOID)(uintptr_t)address, - buffer, - size, - &read_size - ) && read_size == size; -} - - -static BOOL write_exact(HANDLE process, DWORD address, const void *buffer, SIZE_T size) -{ - SIZE_T written_size = 0; - return WriteProcessMemory( - process, - (LPVOID)(uintptr_t)address, - buffer, - size, - &written_size - ) && written_size == size; -} - - -static void emit_byte(CodeBuffer *code, BYTE value) -{ - if (!code->valid || code->length >= code->capacity) - { - code->valid = FALSE; - return; - } - code->data[code->length++] = value; -} - - -static void emit_dword(CodeBuffer *code, DWORD value) -{ - SIZE_T index; - for (index = 0; index < sizeof(value); ++index) - { - emit_byte(code, (BYTE)((value >> (index * 8u)) & 0xffu)); - } -} - - -static void emit_bytes(CodeBuffer *code, const BYTE *values, SIZE_T size) -{ - SIZE_T index; - for (index = 0; index < size; ++index) - { - emit_byte(code, values[index]); - } -} - - -static SIZE_T emit_near_jump_placeholder(CodeBuffer *code, BYTE condition) -{ - SIZE_T displacement_offset; - if (condition != 0) - { - emit_byte(code, 0x0f); - emit_byte(code, condition); - } - else - { - emit_byte(code, 0xe9); - } - displacement_offset = code->length; - emit_dword(code, 0); - return displacement_offset; -} - - -static void patch_near_jump(CodeBuffer *code, SIZE_T displacement_offset, SIZE_T target_offset) -{ - int32_t displacement; - if (!code->valid || displacement_offset + sizeof(displacement) > code->capacity) - { - code->valid = FALSE; - return; - } - displacement = (int32_t)(target_offset - (displacement_offset + sizeof(displacement))); - memcpy(code->data + displacement_offset, &displacement, sizeof(displacement)); -} - - -static void emit_push_immediate(CodeBuffer *code, DWORD value) -{ - emit_byte(code, 0x68); - emit_dword(code, value); -} - - -static void emit_call_iat(CodeBuffer *code, DWORD iat_address) -{ - emit_byte(code, 0xff); - emit_byte(code, 0x15); - emit_dword(code, iat_address); -} - - -static void emit_set_state(CodeBuffer *code, DWORD state_address, BYTE value) -{ - emit_byte(code, 0xc6); - emit_byte(code, 0x05); - emit_dword(code, state_address); - emit_byte(code, value); -} - - -static SIZE_T emit_restore_iat( - CodeBuffer *code, - DWORD iat_address, - DWORD original_lstrcmp, - DWORD virtual_protect_iat, - DWORD original_iat_protect, - DWORD temporary_protect_address -) -{ - SIZE_T protect_failure_jump; - - /* The import table is in publisher .rdata. Make it writable before MOV. */ - emit_push_immediate(code, temporary_protect_address); - emit_push_immediate(code, PAGE_READWRITE); - emit_push_immediate(code, sizeof(DWORD)); - emit_push_immediate(code, iat_address); - emit_call_iat(code, virtual_protect_iat); - emit_bytes(code, (const BYTE[]){0x85, 0xc0}, 2); - protect_failure_jump = emit_near_jump_placeholder(code, 0x84); /* je finish */ - - /* mov dword ptr [iat_address], original_lstrcmp */ - emit_byte(code, 0xc7); - emit_byte(code, 0x05); - emit_dword(code, iat_address); - emit_dword(code, original_lstrcmp); - - emit_push_immediate(code, temporary_protect_address); - emit_push_immediate(code, original_iat_protect); - emit_push_immediate(code, sizeof(DWORD)); - emit_push_immediate(code, iat_address); - emit_call_iat(code, virtual_protect_iat); - return protect_failure_jump; -} - - -static BOOL build_remote_hook( - BYTE *image, - SIZE_T image_size, - DWORD remote_base, - DWORD game_dll_base, - DWORD original_lstrcmp, - DWORD original_iat_protect, - SIZE_T *code_size -) -{ - CodeBuffer code; - DWORD map_entry = address_from_rva(game_dll_base, GAME_DLL_MAP_ENTRY_RVA); - DWORD code_cave = address_from_rva(game_dll_base, GAME_DLL_CODE_CAVE_RVA); - DWORD login_return = address_from_rva(game_dll_base, GAME_DLL_LOGIN_RETURN_RVA); - DWORD lstrcmp_iat = address_from_rva(game_dll_base, GAME_DLL_LSTRCMPA_IAT_RVA); - DWORD virtual_protect_iat = address_from_rva( - game_dll_base, - GAME_DLL_VIRTUAL_PROTECT_IAT_RVA - ); - DWORD flush_iat = address_from_rva( - game_dll_base, - GAME_DLL_FLUSH_INSTRUCTION_CACHE_IAT_RVA - ); - DWORD get_process_iat = address_from_rva( - game_dll_base, - GAME_DLL_GET_CURRENT_PROCESS_IAT_RVA - ); - DWORD reclaim_source = remote_base + REMOTE_RECLAIM_OFFSET; - DWORD jump_source = remote_base + REMOTE_MAP_JUMP_OFFSET; - DWORD state_address = remote_base + REMOTE_STATE_OFFSET; - DWORD old_cave_protect = remote_base + REMOTE_OLD_CAVE_PROTECT_OFFSET; - DWORD old_entry_protect = remote_base + REMOTE_OLD_ENTRY_PROTECT_OFFSET; - DWORD temporary_protect = remote_base + REMOTE_TEMP_PROTECT_OFFSET; - SIZE_T nonzero_jump; - SIZE_T wrong_caller_jump; - SIZE_T already_triggered_jump; - SIZE_T cave_failure_jump; - SIZE_T entry_failure_jump; - SIZE_T success_restore_failure_jump; - SIZE_T failure_restore_failure_jump; - SIZE_T success_jump; - SIZE_T finish_offset; - SIZE_T entry_failure_offset; - SIZE_T failure_offset; - SIZE_T success_offset; - - if (image_size < REMOTE_TEMP_PROTECT_OFFSET + sizeof(DWORD)) - { - return FALSE; - } - memset(image, 0, image_size); - memcpy( - image + REMOTE_RECLAIM_OFFSET, - PROVEN_RECLAIM_HOOK, - sizeof(PROVEN_RECLAIM_HOOK) - ); - memcpy(image + REMOTE_MAP_JUMP_OFFSET, MAP_ENTRY_JUMP, sizeof(MAP_ENTRY_JUMP)); - - code.data = image; - code.capacity = REMOTE_RECLAIM_OFFSET; - code.length = 0; - code.valid = TRUE; - - /* Standard-call lstrcmpA wrapper, preserving every nonvolatile register. */ - emit_bytes(&code, (const BYTE[]){0x55, 0x89, 0xe5, 0x53, 0x56, 0x57}, 6); - emit_bytes(&code, (const BYTE[]){0xff, 0x75, 0x0c, 0xff, 0x75, 0x08}, 6); - emit_byte(&code, 0xb8); - emit_dword(&code, original_lstrcmp); - emit_bytes(&code, (const BYTE[]){0xff, 0xd0, 0x85, 0xc0}, 4); - nonzero_jump = emit_near_jump_placeholder(&code, 0x85); /* jne finish */ - emit_bytes(&code, (const BYTE[]){0x81, 0x7d, 0x04}, 3); - emit_dword(&code, login_return); - wrong_caller_jump = emit_near_jump_placeholder(&code, 0x85); /* jne finish */ - emit_bytes(&code, (const BYTE[]){0x80, 0x3d}, 2); - emit_dword(&code, state_address); - emit_byte(&code, 0x00); - already_triggered_jump = emit_near_jump_placeholder(&code, 0x85); /* jne finish */ - emit_set_state(&code, state_address, 1); - - /* Make the two exact publisher-code ranges writable only after login. */ - emit_push_immediate(&code, old_cave_protect); - emit_push_immediate(&code, PAGE_EXECUTE_READWRITE); - emit_push_immediate(&code, sizeof(PROVEN_RECLAIM_HOOK)); - emit_push_immediate(&code, code_cave); - emit_call_iat(&code, virtual_protect_iat); - emit_bytes(&code, (const BYTE[]){0x85, 0xc0}, 2); - cave_failure_jump = emit_near_jump_placeholder(&code, 0x84); /* je failure */ - - emit_push_immediate(&code, old_entry_protect); - emit_push_immediate(&code, PAGE_EXECUTE_READWRITE); - emit_push_immediate(&code, sizeof(MAP_ENTRY_JUMP)); - emit_push_immediate(&code, map_entry); - emit_call_iat(&code, virtual_protect_iat); - emit_bytes(&code, (const BYTE[]){0x85, 0xc0}, 2); - entry_failure_jump = emit_near_jump_placeholder(&code, 0x84); /* je entry_failure */ - - /* Copy only after the exact account-ID comparison returned success. */ - emit_byte(&code, 0xfc); - emit_byte(&code, 0xbe); - emit_dword(&code, reclaim_source); - emit_byte(&code, 0xbf); - emit_dword(&code, code_cave); - emit_byte(&code, 0xb9); - emit_dword(&code, sizeof(PROVEN_RECLAIM_HOOK)); - emit_bytes(&code, (const BYTE[]){0xf3, 0xa4}, 2); - emit_byte(&code, 0xbe); - emit_dword(&code, jump_source); - emit_byte(&code, 0xbf); - emit_dword(&code, map_entry); - emit_byte(&code, 0xb9); - emit_dword(&code, sizeof(MAP_ENTRY_JUMP)); - emit_bytes(&code, (const BYTE[]){0xf3, 0xa4}, 2); - - /* x86 is coherent, but use the publisher's own imports for an explicit flush. */ - emit_call_iat(&code, get_process_iat); - emit_push_immediate(&code, 0); - emit_push_immediate(&code, 0); - emit_byte(&code, 0x50); - emit_call_iat(&code, flush_iat); - - /* Restore publisher section protections after the five-byte entry change. */ - emit_push_immediate(&code, temporary_protect); - emit_bytes(&code, (const BYTE[]){0xff, 0x35}, 2); - emit_dword(&code, old_entry_protect); - emit_push_immediate(&code, sizeof(MAP_ENTRY_JUMP)); - emit_push_immediate(&code, map_entry); - emit_call_iat(&code, virtual_protect_iat); - emit_push_immediate(&code, temporary_protect); - emit_bytes(&code, (const BYTE[]){0xff, 0x35}, 2); - emit_dword(&code, old_cave_protect); - emit_push_immediate(&code, sizeof(PROVEN_RECLAIM_HOOK)); - emit_push_immediate(&code, code_cave); - emit_call_iat(&code, virtual_protect_iat); - - emit_set_state(&code, state_address, 2); - success_restore_failure_jump = emit_restore_iat( - &code, - lstrcmp_iat, - original_lstrcmp, - virtual_protect_iat, - original_iat_protect, - temporary_protect - ); - success_jump = emit_near_jump_placeholder(&code, 0); /* jmp finish */ - - entry_failure_offset = code.length; - emit_push_immediate(&code, temporary_protect); - emit_bytes(&code, (const BYTE[]){0xff, 0x35}, 2); - emit_dword(&code, old_cave_protect); - emit_push_immediate(&code, sizeof(PROVEN_RECLAIM_HOOK)); - emit_push_immediate(&code, code_cave); - emit_call_iat(&code, virtual_protect_iat); - - failure_offset = code.length; - emit_set_state(&code, state_address, 3); - failure_restore_failure_jump = emit_restore_iat( - &code, - lstrcmp_iat, - original_lstrcmp, - virtual_protect_iat, - original_iat_protect, - temporary_protect - ); - - success_offset = code.length; - emit_bytes(&code, (const BYTE[]){0x31, 0xc0}, 2); /* successful lstrcmpA result */ - finish_offset = code.length; - emit_bytes(&code, (const BYTE[]){0x5f, 0x5e, 0x5b, 0x89, 0xec, 0x5d, 0xc2, 0x08, 0x00}, 9); - - patch_near_jump(&code, nonzero_jump, finish_offset); - patch_near_jump(&code, wrong_caller_jump, finish_offset); - patch_near_jump(&code, already_triggered_jump, success_offset); - patch_near_jump(&code, cave_failure_jump, failure_offset); - patch_near_jump(&code, entry_failure_jump, entry_failure_offset); - patch_near_jump(&code, success_restore_failure_jump, success_offset); - patch_near_jump(&code, failure_restore_failure_jump, success_offset); - patch_near_jump(&code, success_jump, success_offset); - - if (!code.valid) - { - return FALSE; - } - *code_size = code.length; - return TRUE; -} - - -static BOOL remote_iat_restore_is_safe( - const BYTE *image, - SIZE_T code_size, - DWORD game_dll_base, - DWORD original_lstrcmp -) -{ - DWORD iat_address = address_from_rva( - game_dll_base, - GAME_DLL_LSTRCMPA_IAT_RVA - ); - DWORD virtual_protect_iat = address_from_rva( - game_dll_base, - GAME_DLL_VIRTUAL_PROTECT_IAT_RVA - ); - SIZE_T restore_count = 0; - SIZE_T index; - - for (index = 0; index + 10 <= code_size; ++index) - { - if ( - image[index] != 0xc7 - || image[index + 1] != 0x05 - || image_dword(image, index + 2) != iat_address - || image_dword(image, index + 6) != original_lstrcmp - ) - { - continue; - } - ++restore_count; - /* - * Immediately before every IAT MOV, require: - * VirtualProtect(iat, 4, PAGE_READWRITE, ...), TEST, JE. - */ - if ( - index < 29 - || image[index - 29] != 0x68 - || image_dword(image, index - 28) != PAGE_READWRITE - || image[index - 24] != 0x68 - || image_dword(image, index - 23) != sizeof(DWORD) - || image[index - 19] != 0x68 - || image_dword(image, index - 18) != iat_address - || image[index - 14] != 0xff - || image[index - 13] != 0x15 - || image_dword(image, index - 12) != virtual_protect_iat - || image[index - 8] != 0x85 - || image[index - 7] != 0xc0 - || image[index - 6] != 0x0f - || image[index - 5] != 0x84 - ) - { - return FALSE; - } - } - return restore_count == 2; -} - - -static DWORD find_game_dll(DWORD process_id, DWORD *module_size) -{ - HANDLE snapshot; - MODULEENTRY32W module; - DWORD result = 0; - - snapshot = CreateToolhelp32Snapshot( - TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, - process_id - ); - if (snapshot == INVALID_HANDLE_VALUE) - { - return 0; - } - memset(&module, 0, sizeof(module)); - module.dwSize = sizeof(module); - if (Module32FirstW(snapshot, &module)) - { - do - { - if (_wcsicmp(module.szModule, L"game.dll") == 0) - { - result = (DWORD)(uintptr_t)module.modBaseAddr; - *module_size = module.modBaseSize; - break; - } - } - while (Module32NextW(snapshot, &module)); - } - CloseHandle(snapshot); - return result; -} - - -static DWORD wait_for_game_dll( - HANDLE process, - DWORD process_id, - DWORD *module_size -) -{ - ULONGLONG deadline = GetTickCount64() + WORLD_FIX_WAIT_MILLISECONDS; - DWORD base; - while (GetTickCount64() < deadline) - { - if (WaitForSingleObject(process, 25) == WAIT_OBJECT_0) - { - return 0; - } - base = find_game_dll(process_id, module_size); - if (base != 0) - { - return base; - } - Sleep(10); - } - return 0; -} - - -static BOOL verify_game_dll_memory(HANDLE process, DWORD base, DWORD module_size) -{ - BYTE map_entry[sizeof(MAP_ENTRY_ORIGINAL)]; - BYTE login_compare[sizeof(LOGIN_COMPARE_ORIGINAL)]; - BYTE code_cave[sizeof(PROVEN_RECLAIM_HOOK)]; - DWORD iat_values[4]; - - if (module_size < GAME_DLL_MINIMUM_IMAGE_SIZE) - { - return FALSE; - } - if (!read_exact( - process, - address_from_rva(base, GAME_DLL_MAP_ENTRY_RVA), - map_entry, - sizeof(map_entry) - ) || memcmp(map_entry, MAP_ENTRY_ORIGINAL, sizeof(map_entry)) != 0) - { - return FALSE; - } - if (!read_exact( - process, - address_from_rva(base, GAME_DLL_LOGIN_RETURN_RVA), - login_compare, - sizeof(login_compare) - ) || memcmp(login_compare, LOGIN_COMPARE_ORIGINAL, sizeof(login_compare)) != 0) - { - return FALSE; - } - if (!read_exact( - process, - address_from_rva(base, GAME_DLL_CODE_CAVE_RVA), - code_cave, - sizeof(code_cave) - )) - { - return FALSE; - } - for (SIZE_T index = 0; index < sizeof(code_cave); ++index) - { - if (code_cave[index] != 0) - { - return FALSE; - } - } - if (!read_exact( - process, - address_from_rva(base, GAME_DLL_VIRTUAL_PROTECT_IAT_RVA), - &iat_values[0], - sizeof(iat_values[0]) - ) || !read_exact( - process, - address_from_rva(base, GAME_DLL_FLUSH_INSTRUCTION_CACHE_IAT_RVA), - &iat_values[1], - sizeof(iat_values[1]) - ) || !read_exact( - process, - address_from_rva(base, GAME_DLL_GET_CURRENT_PROCESS_IAT_RVA), - &iat_values[2], - sizeof(iat_values[2]) - ) || !read_exact( - process, - address_from_rva(base, GAME_DLL_LSTRCMPA_IAT_RVA), - &iat_values[3], - sizeof(iat_values[3]) - )) - { - return FALSE; - } - return iat_values[0] != 0 && iat_values[1] != 0 - && iat_values[2] != 0 && iat_values[3] != 0; -} - - -static BOOL install_world_fix( - HANDLE process, - DWORD game_dll_base, - WorldFixInstallation *installation -) -{ - BYTE remote_image[REMOTE_ALLOCATION_SIZE]; - DWORD original_lstrcmp; - DWORD original_iat_protect; - DWORD hook_address; - DWORD lstrcmp_iat = address_from_rva( - game_dll_base, - GAME_DLL_LSTRCMPA_IAT_RVA - ); - LPVOID remote; - SIZE_T code_size; - - if (!read_exact( - process, - lstrcmp_iat, - &original_lstrcmp, - sizeof(original_lstrcmp) - )) - { - return FALSE; - } - remote = VirtualAllocEx( - process, - NULL, - REMOTE_ALLOCATION_SIZE, - MEM_COMMIT | MEM_RESERVE, - PAGE_EXECUTE_READWRITE - ); - if (remote == NULL) - { - return FALSE; - } - if (!build_remote_hook( - remote_image, - sizeof(remote_image), - (DWORD)(uintptr_t)remote, - game_dll_base, - original_lstrcmp, - PAGE_READONLY, - &code_size - )) - { - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - return FALSE; - } - if (!write_exact( - process, - (DWORD)(uintptr_t)remote, - remote_image, - sizeof(remote_image) - )) - { - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - return FALSE; - } - if (!VirtualProtectEx( - process, - (LPVOID)(uintptr_t)lstrcmp_iat, - sizeof(DWORD), - PAGE_READWRITE, - &original_iat_protect - )) - { - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - return FALSE; - } - - /* Rebuild so the callback restores the exact protection returned above. */ - if (!build_remote_hook( - remote_image, - sizeof(remote_image), - (DWORD)(uintptr_t)remote, - game_dll_base, - original_lstrcmp, - original_iat_protect, - &code_size - ) || !write_exact( - process, - (DWORD)(uintptr_t)remote, - remote_image, - sizeof(remote_image) - )) - { - DWORD ignored; - VirtualProtectEx( - process, - (LPVOID)(uintptr_t)lstrcmp_iat, - sizeof(DWORD), - original_iat_protect, - &ignored - ); - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - return FALSE; - } - hook_address = (DWORD)(uintptr_t)remote; - if (!write_exact(process, lstrcmp_iat, &hook_address, sizeof(hook_address))) - { - DWORD ignored; - VirtualProtectEx( - process, - (LPVOID)(uintptr_t)lstrcmp_iat, - sizeof(DWORD), - original_iat_protect, - &ignored - ); - VirtualFreeEx(process, remote, 0, MEM_RELEASE); - return FALSE; - } - FlushInstructionCache(process, remote, code_size); - installation->remote_state = (DWORD)(uintptr_t)remote + REMOTE_STATE_OFFSET; - installation->remote_allocation = remote; - return TRUE; -} - - -static void report_login_accepted(StatusLog *status, BOOL *reported) -{ - if (!*reported) - { - write_status_event(status, "login_accepted", NULL); - *reported = TRUE; - } -} - - -static BOOL report_world_fix_state( - StatusLog *status, - BYTE state, - BOOL *login_reported -) -{ - if (state >= 1 && state <= 3) - { - report_login_accepted(status, login_reported); - } - if (state == 2) - { - fprintf( - stderr, - "PTWORLD: login accepted; world-transition fix activated in memory.\n" - ); - write_status_event(status, "world_fix_activated", NULL); - return TRUE; - } - if (state == 3) - { - fprintf( - stderr, - "PTWORLD: login succeeded, but the verified memory fix could not be activated.\n" - ); - write_status_event(status, "world_fix_activation_failed", NULL); - return TRUE; - } - return FALSE; -} - - -static DWORD wait_for_game_and_report( - HANDLE process, - const WorldFixInstallation *installation, - StatusLog *status -) -{ - BOOL reported = FALSE; - BOOL login_reported = FALSE; - for (;;) - { - DWORD wait_result = WaitForSingleObject(process, 100); - if (wait_result == WAIT_OBJECT_0) - { - DWORD exit_code = 1; - if (!reported && installation->remote_state != 0) - { - BYTE state = 0; - if (read_exact(process, installation->remote_state, &state, sizeof(state))) - { - reported = report_world_fix_state( - status, - state, - &login_reported - ); - } - } - if (!reported && installation->remote_state != 0) - { - write_status_event(status, "world_fix_not_activated", NULL); - } - GetExitCodeProcess(process, &exit_code); - return exit_code; - } - if (!reported && installation->remote_state != 0) - { - BYTE state = 0; - if (read_exact(process, installation->remote_state, &state, sizeof(state))) - { - reported = report_world_fix_state( - status, - state, - &login_reported - ); - } - } - } -} - - -static int run_self_test(void) -{ - BYTE image[REMOTE_ALLOCATION_SIZE]; - SIZE_T code_size = 0; - if (!build_remote_hook( - image, - sizeof(image), - 0x50000000u, - 0x10000000u, - 0x7b621000u, - PAGE_READONLY, - &code_size - ) || code_size == 0 || code_size >= REMOTE_RECLAIM_OFFSET) - { - fprintf(stderr, "PTWORLD-SELFTEST: remote hook construction failed.\n"); - return 1; - } - if (!remote_iat_restore_is_safe( - image, - code_size, - 0x10000000u, - 0x7b621000u - )) - { - fprintf( - stderr, - "PTWORLD-SELFTEST: import-table restore is not write-safe.\n" - ); - return 1; - } - if (memcmp( - image + REMOTE_RECLAIM_OFFSET, - PROVEN_RECLAIM_HOOK, - sizeof(PROVEN_RECLAIM_HOOK) - ) != 0 || memcmp( - image + REMOTE_MAP_JUMP_OFFSET, - MAP_ENTRY_JUMP, - sizeof(MAP_ENTRY_JUMP) - ) != 0 || image[REMOTE_STATE_OFFSET] != 0) - { - fprintf(stderr, "PTWORLD-SELFTEST: remote payload layout failed.\n"); - return 1; - } - fprintf( - stdout, - "PTWORLD-SELFTEST: ok; remote code=%lu bytes.\n", - (unsigned long)code_size - ); - return 0; -} - - -int wmain(int argument_count, wchar_t **arguments) -{ - STARTUPINFOW startup; - PROCESS_INFORMATION process; - WorldFixInstallation installation; - StatusLog status; - const wchar_t *status_path = NULL; - WCHAR game_path[MAX_PATH]; - WCHAR command_line[MAX_PATH + 64]; - DWORD game_dll_base; - DWORD game_dll_size = 0; - DWORD exit_code; - DWORD path_length; - - if ( - argument_count == 2 - && wcscmp(arguments[1], L"--self-test") == 0 - ) - { - return run_self_test(); - } - initialize_status_log(&status); - if ( - argument_count == 3 - && wcscmp(arguments[1], L"--status-file") == 0 - ) - { - status_path = arguments[2]; - if (!open_status_log(&status, status_path)) - { - fprintf( - stderr, - "PTWORLD: could not create the per-run status file (Windows error %lu).\n", - (unsigned long)GetLastError() - ); - } - } - else if (argument_count != 1) - { - fprintf(stderr, "PTWORLD: invalid helper arguments.\n"); - return 111; - } - write_status_event( - &status, - "helper_started", - ",\"process_id\":%lu", - (unsigned long)GetCurrentProcessId() - ); - memset(&startup, 0, sizeof(startup)); - memset(&process, 0, sizeof(process)); - memset(&installation, 0, sizeof(installation)); - startup.cb = sizeof(startup); - path_length = GetFullPathNameW(L"Game.exe", MAX_PATH, game_path, NULL); - if (path_length == 0 || path_length >= MAX_PATH) - { - fprintf(stderr, "PTWORLD: Game.exe is not available in the working directory.\n"); - write_status_event(&status, "game_path_failed", NULL); - close_status_log(&status); - return 111; - } - if (swprintf( - command_line, - MAX_PATH + 64, - L"\"%ls\" PTEU_REBORN", - game_path - ) < 0) - { - fprintf(stderr, "PTWORLD: could not construct the game command line.\n"); - write_status_event(&status, "game_command_failed", NULL); - close_status_log(&status); - return 111; - } - if (!CreateProcessW( - game_path, - command_line, - NULL, - NULL, - TRUE, - 0, - NULL, - NULL, - &startup, - &process - )) - { - DWORD error = GetLastError(); - fprintf( - stderr, - "PTWORLD: could not start Game.exe (Windows error %lu).\n", - (unsigned long)error - ); - write_status_event( - &status, - "game_start_failed", - ",\"windows_error\":%lu", - (unsigned long)error - ); - close_status_log(&status); - return 111; - } - CloseHandle(process.hThread); - write_status_event( - &status, - "game_started", - ",\"process_id\":%lu", - (unsigned long)process.dwProcessId - ); - - game_dll_base = wait_for_game_dll( - process.hProcess, - process.dwProcessId, - &game_dll_size - ); - if (game_dll_base != 0) - { - write_status_event( - &status, - "game_dll_found", - ",\"base\":%lu,\"size\":%lu", - (unsigned long)game_dll_base, - (unsigned long)game_dll_size - ); - } - if (game_dll_base == 0) - { - fprintf( - stderr, - "PTWORLD: game.dll was not available for verification; continuing pristine.\n" - ); - write_status_event(&status, "game_dll_unavailable", NULL); - } - else if (!verify_game_dll_memory(process.hProcess, game_dll_base, game_dll_size)) - { - fprintf( - stderr, - "PTWORLD: game.dll memory did not match V21.75.83; continuing pristine.\n" - ); - write_status_event( - &status, - "game_dll_rejected", - ",\"base\":%lu,\"size\":%lu", - (unsigned long)game_dll_base, - (unsigned long)game_dll_size - ); - } - else - { - write_status_event( - &status, - "game_dll_verified", - NULL - ); - if (!install_world_fix(process.hProcess, game_dll_base, &installation)) - { - DWORD error = GetLastError(); - fprintf( - stderr, - "PTWORLD: the post-login hook could not be armed; continuing pristine.\n" - ); - write_status_event( - &status, - "hook_arm_failed", - ",\"windows_error\":%lu", - (unsigned long)error - ); - } - else - { - fprintf( - stderr, - "PTWORLD: publisher game.dll verified; post-login world fix armed.\n" - ); - write_status_event( - &status, - "hook_armed", - ",\"base\":%lu,\"size\":%lu", - (unsigned long)game_dll_base, - (unsigned long)game_dll_size - ); - } - } - - exit_code = wait_for_game_and_report(process.hProcess, &installation, &status); - write_status_event( - &status, - "game_exited", - ",\"exit_code\":%lu", - (unsigned long)exit_code - ); - CloseHandle(process.hProcess); - close_status_log(&status); - return (int)exit_code; -} diff --git a/ptlinux/cli.py b/ptlinux/cli.py index 551b012..0bd1f71 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -25,6 +25,7 @@ from .depot import SyncProgress, sync_managed_game from .doctor import inspect_host from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher from .importer import GameImportResult, discover_existing_game, import_existing_game +from .import_state import game_import_requires_verification, mark_game_import_verified from .launcher import ( LauncherError, configure_launcher_prefix, @@ -292,7 +293,6 @@ def main(argv: Sequence[str] | None = None) -> int: paths, arguments.game_dir, arguments.no_box, - arguments.publisher_dll, arguments.dry_run, arguments.skip_runtime_setup, arguments.json, @@ -422,10 +422,6 @@ def _login_diagnostics( f"Depot: build {report.depot_build} " f"({report.depot_version or 'unknown version'})" ) - world_fix_status = "enabled" if report.world_fix_enabled else "retired" - print(f"World fix: {world_fix_status}") - if report.world_fix_disabled_reason: - print(f"Reason: {report.world_fix_disabled_reason}") if report.runtime_kind: print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})") else: @@ -532,13 +528,22 @@ def _native_update( runner, announce=not as_json, ) + verify_import = game_import is not None or game_import_requires_verification(paths) _print_operation_status(as_json, 2, 3, "Synchronizing official game files…") result = sync_managed_game( paths, - repair=repair, + repair=repair or verify_import, + quarantine_unmanaged=verify_import, dry_run=dry_run, progress=None if as_json else _print_sync_progress, ) + if verify_import and not dry_run: + mark_game_import_verified( + paths, + build=result.build, + files_quarantined=result.files_quarantined, + quarantine_directory=result.quarantine_directory, + ) overlay = None if not dry_run and paths.source_game.is_dir(): _print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…") @@ -587,6 +592,23 @@ def _wine_update( announce=not as_json, ) updater = launch_official_updater(paths, tools, runner) + native_verification = None + verify_import = not dry_run and ( + game_import is not None or game_import_requires_verification(paths) + ) + if verify_import: + native_verification = sync_managed_game( + paths, + repair=True, + quarantine_unmanaged=True, + progress=None if as_json else _print_sync_progress, + ) + mark_game_import_verified( + paths, + build=native_verification.build, + files_quarantined=native_verification.files_quarantined, + quarantine_directory=native_verification.quarantine_directory, + ) overlay = None if not dry_run and paths.source_game.is_dir(): overlay = asdict(prepare_runtime_view(paths, paths.source_game)) @@ -595,6 +617,9 @@ def _wine_update( "dry_run": dry_run, "game_import": asdict(game_import) if game_import else None, "updater": asdict(updater), + "native_verification": ( + asdict(native_verification) if native_verification is not None else None + ), "runtime_view": overlay, } _emit(value, as_json) @@ -662,6 +687,11 @@ def _maybe_import_game( def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: + if game_dir is None and game_import_requires_verification(paths): + raise LauncherError( + "the imported game has not completed its full publisher verification; " + "run pristontale-eu update" + ) settings = ensure_settings(paths) source = _source_game(paths, settings, game_dir) result = prepare_runtime_view(paths, source) @@ -673,7 +703,6 @@ def _play( paths: AppPaths, game_dir: Path | None, no_box: bool, - publisher_dll: bool, dry_run: bool, skip_runtime_setup: bool, as_json: bool, @@ -683,7 +712,6 @@ def _play( paths, game_dir, no_box, - publisher_dll, dry_run, skip_runtime_setup, as_json, @@ -694,14 +722,15 @@ def _play_locked( paths: AppPaths, game_dir: Path | None, no_box: bool, - publisher_dll: bool, dry_run: bool, skip_runtime_setup: bool, as_json: bool, ) -> int: - # The legacy flag is retained for command-line compatibility. V21 performs - # periodic module-code checks, so every production launch is publisher-pristine. - _ = publisher_dll + if game_dir is None and game_import_requires_verification(paths): + raise LauncherError( + "the imported game has not completed its full publisher verification; " + "run pristontale-eu update before playing" + ) settings = _load_or_default_settings(paths, persist=not dry_run) source = _source_game(paths, settings, game_dir) overlay = None @@ -729,7 +758,6 @@ def _play_locked( selection, runner, use_gamebox=not no_box, - use_world_fix=False, ) except KeyboardInterrupt: current_game_log = _current_game_log( @@ -751,7 +779,6 @@ def _play_locked( and runtime_issues["host_futex_fatal_count"] ): runtime_issues["recommended_one_variable_comparison"] = "proton7" - world_fix_status = None _emit( { "ok": outcome.returncode == 0, @@ -762,7 +789,6 @@ def _play_locked( "game": asdict(outcome), "current_game_log": current_game_log, "runtime_issues": runtime_issues, - "world_fix": world_fix_status, }, as_json, ) diff --git a/ptlinux/depot.py b/ptlinux/depot.py index 7ba87e7..960a13a 100644 --- a/ptlinux/depot.py +++ b/ptlinux/depot.py @@ -13,6 +13,7 @@ import json import os from pathlib import Path, PurePosixPath import re +import stat import threading import time from typing import Callable, Iterable, Mapping, Protocol @@ -38,6 +39,35 @@ DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0) PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075 MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024 MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024 +UNMANAGED_EXECUTABLE_SUFFIXES = frozenset( + { + ".asi", + ".bat", + ".cmd", + ".com", + ".cpl", + ".desktop", + ".dll", + ".drv", + ".exe", + ".jar", + ".js", + ".lnk", + ".lua", + ".msi", + ".ocx", + ".ps1", + ".py", + ".reg", + ".scr", + ".sh", + ".so", + ".url", + ".vbe", + ".vbs", + ".wsf", + } +) ALWAYS_VERIFY_PATHS = frozenset( {"game.exe", "game.dll", "gamese.exe", "gamese.dll"} ) @@ -132,11 +162,21 @@ class DepotSyncResult: bytes_downloaded: int files_deleted: int files_created: int + files_quarantined: int + quarantine_directory: str | None repair: bool dry_run: bool completed_at: str +@dataclass(frozen=True) +class QuarantineResult: + """Recoverable unmanaged entries removed before authenticating an import.""" + + files: int + directory: str | None + + ProgressCallback = Callable[[SyncProgress], None] @@ -486,6 +526,7 @@ def sync_managed_game( paths: AppPaths, *, repair: bool = False, + quarantine_unmanaged: bool = False, dry_run: bool = False, progress: ProgressCallback | None = None, client: DepotClient | None = None, @@ -496,6 +537,9 @@ def sync_managed_game( depot_client = client or DepotClient() _emit_progress(progress, "manifest", 0, 0, 0, 0, None) snapshot = depot_client.fetch_snapshot() + quarantine = QuarantineResult(0, None) + if quarantine_unmanaged and not dry_run: + quarantine = _quarantine_unmanaged_files(paths, snapshot) plan = plan_update(paths.source_game, snapshot, repair=repair) _emit_progress( progress, @@ -508,7 +552,16 @@ def sync_managed_game( ) if dry_run: - return _sync_result(snapshot, plan, 0, 0, 0, repair, True) + return _sync_result( + snapshot, + plan, + 0, + 0, + 0, + repair, + True, + quarantine=quarantine, + ) root = _prepare_game_root(paths.source_game, create=True) _download_plan(depot_client, snapshot, root, plan, progress) @@ -523,6 +576,7 @@ def sync_managed_game( repair, False, created=created, + quarantine=quarantine, ) _write_json(paths.native_depot_state, asdict(result)) _emit_progress( @@ -537,6 +591,110 @@ def sync_managed_game( return result +def _quarantine_unmanaged_files( + paths: AppPaths, + snapshot: DepotSnapshot, +) -> QuarantineResult: + """Move non-depot import contents aside without following links.""" + + root = _prepare_game_root(paths.source_game, create=False) + if not root.exists(): + return QuarantineResult(0, None) + allowed = { + file.path for file in snapshot.files + } | { + entry.path for entry in snapshot.create_if_missing + } + candidates: list[tuple[Path, str]] = [] + + def walk_error(error: OSError) -> None: + raise DepotError(f"cannot inspect imported game tree: {error}") from error + + for directory, directory_names, file_names in os.walk( + root, + topdown=True, + followlinks=False, + onerror=walk_error, + ): + parent = Path(directory) + for name in tuple(directory_names): + path = parent / name + try: + status = path.lstat() + except OSError as exc: + raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc + if stat.S_ISDIR(status.st_mode): + continue + directory_names.remove(name) + candidates.append((path, path.relative_to(root).as_posix())) + for name in file_names: + path = parent / name + relative = path.relative_to(root).as_posix() + try: + status = path.lstat() + except OSError as exc: + raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc + if not stat.S_ISREG(status.st_mode): + candidates.append((path, relative)) + continue + if relative in allowed: + continue + if _safe_imported_user_file(path, relative): + continue + candidates.append((path, relative)) + + if not candidates: + return QuarantineResult(0, None) + base = paths.game_import_quarantine.resolve(strict=False) + data_root = paths.data.resolve(strict=False) + try: + base.relative_to(data_root) + except ValueError as exc: + raise DepotError(f"import quarantine is outside the data root: {base}") from exc + stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S") + quarantine = base / f"{stamp}-{uuid4().hex}" + files_root = quarantine / "files" + files_root.mkdir(parents=True, exist_ok=False) + manifest = { + "schema": 1, + "complete": False, + "created_at": datetime.now().astimezone().isoformat(), + "source": str(root), + "files": [relative for _path, relative in candidates], + } + _write_json(quarantine / "quarantine.json", manifest) + for source, relative in candidates: + destination = files_root.joinpath(*PurePosixPath(relative).parts) + destination.parent.mkdir(parents=True, exist_ok=True) + try: + source.replace(destination) + except OSError as exc: + raise DepotError( + f"could not quarantine imported entry {relative}: {exc}" + ) from exc + manifest["complete"] = True + _write_json(quarantine / "quarantine.json", manifest) + return QuarantineResult(len(candidates), str(quarantine)) + + +def _safe_imported_user_file(path: Path, relative: str) -> bool: + parts = PurePosixPath(relative).parts + if not parts or parts[0].casefold() != "save": + return False + if path.suffix.casefold() in UNMANAGED_EXECUTABLE_SUFFIXES: + return False + try: + with path.open("rb") as handle: + prefix = handle.read(4) + except OSError as exc: + raise DepotError(f"cannot inspect imported user file {path}: {exc}") from exc + return not ( + prefix.startswith(b"MZ") + or prefix.startswith(b"\x7fELF") + or prefix.startswith(b"#!") + ) + + def _download_plan( client: DepotClient, depot_snapshot: DepotSnapshot, @@ -699,6 +857,7 @@ def _sync_result( dry_run: bool, *, created: int = 0, + quarantine: QuarantineResult = QuarantineResult(0, None), ) -> DepotSyncResult: return DepotSyncResult( region=snapshot.region, @@ -711,6 +870,8 @@ def _sync_result( bytes_downloaded=bytes_downloaded, files_deleted=files_deleted, files_created=created, + files_quarantined=quarantine.files, + quarantine_directory=quarantine.directory, repair=repair, dry_run=dry_run, completed_at=datetime.now().astimezone().isoformat(), diff --git a/ptlinux/importer.py b/ptlinux/importer.py index 7997542..7a8c4a5 100644 --- a/ptlinux/importer.py +++ b/ptlinux/importer.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path import shutil +import stat from typing import Mapping import uuid @@ -43,6 +44,7 @@ class GameImportResult: installed_build: int | None started_at: str completed_at: str + verification_required: bool def import_existing_game( @@ -87,10 +89,13 @@ def import_existing_game( ), started_at=started_at, completed_at=started_at, + verification_required=True, ) destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_name(f".{destination.name}.import-{uuid.uuid4().hex}") + committed = False + pending_recorded = False try: temporary.mkdir(parents=False, exist_ok=False) runner.run( @@ -111,8 +116,31 @@ def import_existing_game( ): cache_compatibility_binaries(paths, source) temporary.replace(destination) + committed = True + provisional = GameImportResult( + source=str(source), + destination=str(destination), + copy_mode="reflink-or-copy", + files=source_before.files, + bytes=source_before.bytes, + core_sha256=source_before.core_sha256, + depot_metadata_source=( + str(depot_metadata.path) if depot_metadata is not None else None + ), + depot_metadata_destination=None, + installed_build=( + depot_metadata.installed_build if depot_metadata is not None else None + ), + started_at=started_at, + completed_at=datetime.now().astimezone().isoformat(), + verification_required=True, + ) + _write_import_manifest(paths, provisional) + pending_recorded = True except BaseException: _remove_staging_tree(temporary, destination.parent) + if committed and not pending_recorded: + _remove_staging_tree(destination, destination.parent) raise metadata_destination = None @@ -140,8 +168,9 @@ def import_existing_game( ), started_at=started_at, completed_at=datetime.now().astimezone().isoformat(), + verification_required=True, ) - _write_json(paths.game_import_manifest, asdict(result)) + _write_import_manifest(paths, result) return result @@ -202,22 +231,48 @@ def inventory_game_tree(source_game: Path) -> GameTreeInventory: source = source_game.expanduser().resolve(strict=True) for relative in REQUIRED_GAME_FILES: path = source / relative - if not path.is_file(): + try: + status = path.lstat() + except OSError: raise GameImportError(f"game installation is incomplete: missing {relative}") + if stat.S_ISLNK(status.st_mode): + raise GameImportError(f"game installation contains a symbolic link: {relative}") + if not stat.S_ISREG(status.st_mode): + raise GameImportError(f"game installation contains a non-file: {relative}") files = 0 total_bytes = 0 - for root, _directories, filenames in os.walk(source, followlinks=False): + + def walk_error(error: OSError) -> None: + raise GameImportError(f"cannot inventory game tree: {error}") from error + + for root, directories, filenames in os.walk( + source, + followlinks=False, + onerror=walk_error, + ): root_path = Path(root) + for directory in directories: + path = root_path / directory + try: + status = path.lstat() + except OSError as exc: + raise GameImportError(f"cannot inventory game directory {path}: {exc}") from exc + if stat.S_ISLNK(status.st_mode): + raise GameImportError(f"game installation contains a symbolic link: {path}") + if not stat.S_ISDIR(status.st_mode): + raise GameImportError(f"game installation contains a special entry: {path}") for filename in filenames: path = root_path / filename - if path.is_symlink(): - files += 1 - continue try: - total_bytes += path.stat().st_size + status = path.lstat() except OSError as exc: raise GameImportError(f"cannot inventory game file {path}: {exc}") from exc + if stat.S_ISLNK(status.st_mode): + raise GameImportError(f"game installation contains a symbolic link: {path}") + if not stat.S_ISREG(status.st_mode): + raise GameImportError(f"game installation contains a special entry: {path}") + total_bytes += status.st_size files += 1 return GameTreeInventory( files=files, @@ -327,6 +382,21 @@ def _sha256(path: Path) -> str: def _write_json(path: Path, value: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") - temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") - temporary.replace(path) + temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + try: + with temporary.open("x", encoding="utf-8") as handle: + json.dump(value, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temporary.replace(path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + +def _write_import_manifest(paths: AppPaths, result: GameImportResult) -> None: + _write_json( + paths.game_import_manifest, + {"schema": 2, **asdict(result)}, + ) diff --git a/ptlinux/install_status.py b/ptlinux/install_status.py index 9a08d09..1af233f 100644 --- a/ptlinux/install_status.py +++ b/ptlinux/install_status.py @@ -7,6 +7,7 @@ from enum import Enum import json from pathlib import Path +from .import_state import game_import_requires_verification from .paths import AppPaths @@ -70,6 +71,8 @@ def inspect_game_installation( local_build = _read_verified_local_build(paths.native_depot_state) if not installed: action = PrimaryAction.INSTALL + elif game_import_requires_verification(paths): + action = PrimaryAction.UPDATE elif official_build is not None and ( local_build is None or local_build < official_build ): diff --git a/ptlinux/login_diagnostics.py b/ptlinux/login_diagnostics.py index 8f85459..a59cc4a 100644 --- a/ptlinux/login_diagnostics.py +++ b/ptlinux/login_diagnostics.py @@ -36,12 +36,6 @@ from .runtime import ( select_runtime, ) from .settings import Settings -from .world_fix import ( - WORLD_FIX_DISABLED_REASON, - WORLD_FIX_ENABLED, - WORLD_FIX_HELPER_SHA256, - packaged_world_fix_helper, -) LOGIN_SERVER_HOST = "5.135.35.188" @@ -149,11 +143,6 @@ class LoginDiagnosticReport: depot_build: int | None depot_version: str | None depot_completed_at: str | None - world_fix_helper: str - world_fix_helper_sha256: str | None - world_fix_helper_ready: bool - world_fix_enabled: bool - world_fix_disabled_reason: str | None runtime_configured: str runtime_requested: str runtime_override_applied: bool @@ -237,7 +226,7 @@ def inspect_login_environment( and selection.kind in {"proton7", "proton7-container"} else None ) - game_root = _active_game_root(paths, settings) + game_root = _active_game_root(paths) game_exe = game_root / "Game.exe" game_dll = game_root / "game.dll" game_exe_sha256 = _sha256_if_file(game_exe) @@ -252,12 +241,6 @@ def inspect_login_environment( ) game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256) depot_state = _read_json_object(paths.native_depot_state) - world_fix_helper = packaged_world_fix_helper() - world_fix_helper_sha256 = _sha256_if_file(world_fix_helper) - world_fix_helper_ready = ( - WORLD_FIX_ENABLED - and world_fix_helper_sha256 == WORLD_FIX_HELPER_SHA256 - ) prefix = _runtime_prefix(paths, selection) prefix_ready = _prefix_ready(prefix) directx_ready = native_d3d_ready(prefix) if prefix is not None else False @@ -271,7 +254,7 @@ def inspect_login_environment( if selection is not None and prefix_ready and runner is not None else None ) - latest_log = latest_native_game_log(paths, settings) + latest_log = latest_native_game_log(paths) warnings = _diagnostic_warnings( probe, game_profile, @@ -333,13 +316,6 @@ def inspect_login_environment( depot_build=_integer_or_none(depot_state.get("build")), depot_version=_string_or_none(depot_state.get("version")), depot_completed_at=_string_or_none(depot_state.get("completed_at")), - world_fix_helper=str(world_fix_helper), - world_fix_helper_sha256=world_fix_helper_sha256, - world_fix_helper_ready=world_fix_helper_ready, - world_fix_enabled=WORLD_FIX_ENABLED, - world_fix_disabled_reason=( - None if WORLD_FIX_ENABLED else WORLD_FIX_DISABLED_REASON - ), runtime_configured=settings.runtime, runtime_requested=runtime_requested, runtime_override_applied=runtime_override is not None, @@ -473,7 +449,7 @@ def classify_game_profile( return "unknown" -def latest_native_game_log(paths: AppPaths, settings: Settings) -> Path | None: +def latest_native_game_log(paths: AppPaths) -> Path | None: """Return the newest native log from the active managed source or runtime view.""" directories = (paths.runtime_view / "save/logs", paths.source_game / "save/logs") @@ -494,7 +470,7 @@ def latest_native_game_log(paths: AppPaths, settings: Settings) -> Path | None: return None -def _active_game_root(paths: AppPaths, settings: Settings) -> Path: +def _active_game_root(paths: AppPaths) -> Path: if paths.runtime_view.is_dir(): return paths.runtime_view return paths.source_game diff --git a/ptlinux/native/pt-world-fix.exe b/ptlinux/native/pt-world-fix.exe deleted file mode 100755 index 331de1f..0000000 Binary files a/ptlinux/native/pt-world-fix.exe and /dev/null differ diff --git a/ptlinux/overlay.py b/ptlinux/overlay.py index ad56d19..1f33903 100644 --- a/ptlinux/overlay.py +++ b/ptlinux/overlay.py @@ -19,7 +19,6 @@ from ptdiag.assbin import ( ) from .paths import AppPaths -from .world_fix import V21_BINARY_HASHES WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug") @@ -35,7 +34,10 @@ COMPATIBILITY_BINARY_HASHES = { "Game.exe": "3cc0980aac0f65b379ff340ec61750498fb9f620fb6c88aa66281e8ab27023a5", "game.dll": "7ea85b19f452962a39c91f967cad22495f066b2d52e8ac37f4e234463d5c4f09", } -CURRENT_V21_BINARY_HASHES = V21_BINARY_HASHES +CURRENT_V21_BINARY_HASHES = { + "Game.exe": "2bc3edd26c5f6d7ae9bbdc23967c5aefd374bdf934fcdfa2ed42c6442e550837", + "game.dll": "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868", +} PATCHED_V21_GAME_DLL_SHA256 = ( "0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538" ) @@ -87,8 +89,6 @@ class RuntimeCompatibility: def inspect_runtime_compatibility( paths: AppPaths, source_game: Path, - *, - use_world_fix: bool = True, ) -> RuntimeCompatibility: """Check whether ``prepare_runtime_view`` can recognize the installed release. @@ -109,9 +109,6 @@ def inspect_runtime_compatibility( inspection = inspect_wrapped_assbin(source_scene) source_scene_sha256 = inspection.file_sha256 scene_action = _scene_action(inspection) - # Retain the keyword for callers from the 0.5.8 candidate. The runtime - # view and loaded process are now always publisher-pristine. - _ = use_world_fix binary_action = _inspect_runtime_binary_action(paths, source) except (OSError, ValueError, OverlayError) as exc: reason = str(exc).strip() or type(exc).__name__ @@ -136,8 +133,6 @@ def inspect_runtime_compatibility( def prepare_runtime_view( paths: AppPaths, source_game: Path, - *, - use_world_fix: bool = True, ) -> OverlayResult: """Build a symlink view and keep the launcher-managed source read-only.""" @@ -148,9 +143,6 @@ def prepare_runtime_view( inspection = inspect_wrapped_assbin(source_scene) action = _scene_action(inspection) game_save = _prepare_persistent_save(paths, source) - # ``use_world_fix`` is retained as a compatibility keyword. The retired - # helper is never selected and no code path may replace the publisher DLL. - _ = use_world_fix ( binary_action, game_exe_source, diff --git a/ptlinux/paths.py b/ptlinux/paths.py index 256e9b7..da90c08 100644 --- a/ptlinux/paths.py +++ b/ptlinux/paths.py @@ -144,6 +144,12 @@ class AppPaths: def game_import_manifest(self) -> Path: return self.state / "game-import.json" + @property + def game_import_quarantine(self) -> Path: + """Recoverable files excluded while authenticating an imported game.""" + + return self.data / "import-quarantine" + @property def native_depot_state(self) -> Path: """Last completed native depot synchronization.""" diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index 10013e3..8e9ab51 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -17,11 +17,6 @@ from .command import CommandOutcome, CommandRunner, OutputLineFilter from .gamebox import build_gamebox_command from .paths import AppPaths from .settings import Settings -from .world_fix import ( - WorldFixError, - WorldFixStatusTarget, - resolve_world_fix_helper, -) WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe" @@ -289,8 +284,6 @@ def run_game( runner: CommandRunner, *, use_gamebox: bool = True, - use_world_fix: bool = False, - world_fix_status_target: WorldFixStatusTarget | None = None, timeout_seconds: float = 12 * 60 * 60, check: bool = True, extra_environment: Mapping[str, str] | None = None, @@ -298,38 +291,21 @@ def run_game( ) -> CommandOutcome: if not paths.runtime_view.is_dir() and not runner.dry_run: raise RuntimeErrorPT("runtime view is missing; prepare the game first") - world_fix_helper = None - if use_world_fix and not runner.dry_run: - try: - world_fix_helper = resolve_world_fix_helper(paths.runtime_view) - except WorldFixError as exc: - raise RuntimeErrorPT(str(exc)) from exc - launch_executable = world_fix_helper or (paths.runtime_view / "Game.exe") if selection.kind == "umu": game_command = [ selection.executable, - str(launch_executable), + str(paths.runtime_view / "Game.exe"), + "PTEU_REBORN", ] - if world_fix_helper is None: - game_command.append("PTEU_REBORN") - elif world_fix_status_target is not None: - game_command.extend( - ["--status-file", world_fix_status_target.windows_path] - ) environment = _umu_environment(paths) elif selection.kind == "proton7-container": if not selection.proton_path: raise RuntimeErrorPT("Proton script path is missing") game_command = [ selection.executable, - str(launch_executable), + str(paths.runtime_view / "Game.exe"), + "PTEU_REBORN", ] - if world_fix_helper is None: - game_command.append("PTEU_REBORN") - elif world_fix_status_target is not None: - game_command.extend( - ["--status-file", world_fix_status_target.windows_path] - ) environment = _umu_proton7_environment( paths, selection, @@ -344,14 +320,9 @@ def run_game( "ptlinux.proton_compat", selection.proton_path, "runinprefix", - str(world_fix_helper) if world_fix_helper is not None else WINDOWS_GAME_EXE, + WINDOWS_GAME_EXE, + "PTEU_REBORN", ] - if world_fix_helper is None: - game_command.append("PTEU_REBORN") - elif world_fix_status_target is not None: - game_command.extend( - ["--status-file", world_fix_status_target.windows_path] - ) environment = _proton_environment( paths, selection, diff --git a/ptlinux/ui.py b/ptlinux/ui.py index 81270e9..6e66480 100644 --- a/ptlinux/ui.py +++ b/ptlinux/ui.py @@ -6,6 +6,7 @@ import base64 from collections import deque import json import math +import os import queue import subprocess import sys @@ -21,6 +22,7 @@ from .install_status import ( PrimaryAction, inspect_game_installation, ) +from .import_state import game_import_requires_verification from .news import ( Broadcast, NewsArticle, @@ -63,6 +65,34 @@ class DashboardError(RuntimeError): """Raised when the native dashboard cannot be displayed.""" +def launcher_restart_spec( + paths: AppPaths, + environment: Mapping[str, str] | None = None, +) -> tuple[list[str], dict[str, str]]: + """Build an isolated restart that imports the newly activated release.""" + + current = paths.launcher_current + if not current.is_symlink(): + raise DashboardError(f"launcher current path is not a symbolic link: {current}") + try: + release = current.resolve(strict=True) + except OSError as exc: + raise DashboardError(f"launcher current release is unavailable: {exc}") from exc + releases = paths.launcher_releases.resolve(strict=False) + if release.parent != releases or not (release / "ptlinux").is_dir(): + raise DashboardError(f"launcher current release is outside the release store: {release}") + env = dict(os.environ if environment is None else environment) + env.update( + { + "PTLINUX_INSTALL_DIR": str(paths.data), + "PTLINUX_APP_HOME": str(paths.app_home), + "PYTHONSAFEPATH": "1", + "PYTHONPATH": str(release), + } + ) + return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env + + class ProgressCoalescer: """Keep only the latest cross-thread progress event for the Tk loop. @@ -1160,7 +1190,7 @@ def run_dashboard(paths: AppPaths) -> None: runtime_compatibility = None local_status = inspect_game_installation(paths) refresh_primary_action(update_activity=update_activity) - if not local_status.installed: + if not local_status.installed or game_import_requires_verification(paths): return def worker() -> None: @@ -1279,8 +1309,10 @@ def run_dashboard(paths: AppPaths) -> None: if value == 0: append_log("Launcher update activated; restarting the dashboard.") try: + restart_command, restart_environment = launcher_restart_spec(paths) subprocess.Popen( - command_for("gui"), + restart_command, + env=restart_environment, close_fds=True, start_new_session=True, ) diff --git a/ptlinux/world_fix.py b/ptlinux/world_fix.py deleted file mode 100644 index 19f6203..0000000 --- a/ptlinux/world_fix.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Retired V21 world-helper metadata and legacy status-file parsing.""" - -from __future__ import annotations - -from dataclasses import dataclass -import json -import os -from pathlib import Path, PureWindowsPath - - -V21_BINARY_HASHES = { - "Game.exe": "2bc3edd26c5f6d7ae9bbdc23967c5aefd374bdf934fcdfa2ed42c6442e550837", - "game.dll": "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868", -} -WORLD_FIX_HELPER_FILENAME = "pt-world-fix.exe" -# Filled from the reproducible stripped PE after the native helper is built. -WORLD_FIX_HELPER_SHA256 = ( - "a7a17d29ef4c11f9603790f0110a4d6823b91cce03f70e466b7e0bdbd6fa313e" -) -WORLD_FIX_ENABLED = False -WORLD_FIX_DISABLED_REASON = ( - "the experimental world fix is retired because PristonTale V21 periodically " - "checks the loaded game.dll code section; its five-byte map-loader detour " - "causes Module Code Sync Error / Banned Code 1" -) -WORLD_FIX_STATUS_PROTOCOL = 1 -WORLD_FIX_STATUS_MAX_BYTES = 64 * 1024 - - -@dataclass(frozen=True) -class WorldFixStatusTarget: - path: Path - windows_path: str - - -class WorldFixError(RuntimeError): - """Raised when the retired helper is requested.""" - - -def packaged_world_fix_helper() -> Path: - return Path(__file__).resolve().parent / "native" / WORLD_FIX_HELPER_FILENAME - - -def resolve_world_fix_helper( - game_directory: Path, - *, - helper: Path | None = None, -) -> Path | None: - """Reject the retired helper before it can alter publisher module memory.""" - - _ = game_directory, helper - raise WorldFixError(WORLD_FIX_DISABLED_REASON) - - -def create_world_fix_status_target( - game_save: Path, - started_ns: int, -) -> WorldFixStatusTarget: - """Allocate a unique launcher-owned status path without pre-creating it.""" - - if started_ns <= 0: - raise WorldFixError("world-fix status start time must be positive") - directory = game_save / "launcher" - if directory.is_symlink(): - raise WorldFixError( - "the world-fix status directory must not be a symbolic link" - ) - directory.mkdir(parents=True, exist_ok=True) - if not directory.is_dir(): - raise WorldFixError("the world-fix status directory is unavailable") - filename = f"ptworld-{started_ns}-{os.getpid()}.jsonl" - path = directory / filename - if path.exists() or path.is_symlink(): - raise WorldFixError(f"world-fix status target already exists: {path}") - windows_path = str(PureWindowsPath("save", "launcher", filename)) - return WorldFixStatusTarget(path=path, windows_path=windows_path) - - -def read_world_fix_status( - target: WorldFixStatusTarget | None, - started_ns: int, -) -> dict[str, object] | None: - """Read only the bounded, fresh status stream belonging to this game run.""" - - if target is None: - return None - report: dict[str, object] = { - "path": str(target.path), - "status": "missing", - "fresh": False, - "events": [], - } - try: - if target.path.is_symlink() or not target.path.is_file(): - return report - state = target.path.stat() - except OSError as exc: - report["status"] = "unreadable" - report["error"] = str(exc) - return report - report["modified_at_ns"] = state.st_mtime_ns - report["size"] = state.st_size - if state.st_mtime_ns < started_ns: - report["status"] = "stale" - return report - report["fresh"] = True - if state.st_size > WORLD_FIX_STATUS_MAX_BYTES: - report["status"] = "oversized" - return report - try: - lines = target.path.read_text(encoding="utf-8").splitlines() - except OSError as exc: - report["status"] = "unreadable" - report["error"] = str(exc) - return report - - events: list[dict[str, object]] = [] - invalid_lines = 0 - for line in lines[:64]: - try: - value = json.loads(line) - except (json.JSONDecodeError, TypeError): - invalid_lines += 1 - continue - clean = _validated_status_event(value) - if clean is None: - invalid_lines += 1 - continue - events.append(clean) - report["events"] = events - if invalid_lines: - report["invalid_lines"] = invalid_lines - event_names = { - event["event"] - for event in events - if isinstance(event.get("event"), str) - } - for name in ( - "helper_started", - "game_started", - "game_dll_verified", - "hook_armed", - "login_accepted", - "world_fix_activated", - ): - report[name] = name in event_names - if "world_fix_activated" in event_names: - report["status"] = "activated" - elif "world_fix_activation_failed" in event_names: - report["status"] = "activation_failed" - elif "world_fix_not_activated" in event_names: - report["status"] = "not_activated" - elif "hook_armed" in event_names: - report["status"] = "armed" - elif event_names & { - "game_path_failed", - "game_command_failed", - "game_start_failed", - "game_dll_unavailable", - "game_dll_rejected", - "hook_arm_failed", - }: - report["status"] = "helper_failed" - elif events: - report["status"] = "started" - else: - report["status"] = "invalid" - return report - - -def _validated_status_event(value: object) -> dict[str, object] | None: - if not isinstance(value, dict): - return None - if value.get("protocol") != WORLD_FIX_STATUS_PROTOCOL: - return None - event = value.get("event") - tick_ms = value.get("tick_ms") - if not isinstance(event, str) or not event or len(event) > 64: - return None - if isinstance(tick_ms, bool) or not isinstance(tick_ms, int) or tick_ms < 0: - return None - clean: dict[str, object] = { - "event": event, - "tick_ms": tick_ms, - } - for key in ("process_id", "base", "size", "windows_error", "exit_code"): - item = value.get(key) - if isinstance(item, bool) or not isinstance(item, int) or item < 0: - continue - clean[key] = item - return clean diff --git a/pyproject.toml b/pyproject.toml index 188419f..0281e26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "ptdiag" +name = "PTEU-Linux-Launcher" version = "0.6.0" description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness" requires-python = ">=3.11"