Added more diagnostics to the native world fixer and py logs system.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
#include <windows.h>
|
||||
#include <tlhelp32.h>
|
||||
#include <stdint.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <wchar.h>
|
||||
@@ -70,12 +71,131 @@ typedef struct WorldFixInstallation
|
||||
} 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;
|
||||
@@ -188,7 +308,7 @@ static void emit_set_state(CodeBuffer *code, DWORD state_address, BYTE value)
|
||||
}
|
||||
|
||||
|
||||
static void emit_restore_iat(
|
||||
static SIZE_T emit_restore_iat(
|
||||
CodeBuffer *code,
|
||||
DWORD iat_address,
|
||||
DWORD original_lstrcmp,
|
||||
@@ -197,6 +317,17 @@ static void emit_restore_iat(
|
||||
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);
|
||||
@@ -208,6 +339,7 @@ static void emit_restore_iat(
|
||||
emit_push_immediate(code, sizeof(DWORD));
|
||||
emit_push_immediate(code, iat_address);
|
||||
emit_call_iat(code, virtual_protect_iat);
|
||||
return protect_failure_jump;
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +381,8 @@ static BOOL build_remote_hook(
|
||||
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;
|
||||
@@ -343,7 +477,8 @@ static BOOL build_remote_hook(
|
||||
emit_push_immediate(&code, code_cave);
|
||||
emit_call_iat(&code, virtual_protect_iat);
|
||||
|
||||
emit_restore_iat(
|
||||
emit_set_state(&code, state_address, 2);
|
||||
success_restore_failure_jump = emit_restore_iat(
|
||||
&code,
|
||||
lstrcmp_iat,
|
||||
original_lstrcmp,
|
||||
@@ -351,7 +486,6 @@ static BOOL build_remote_hook(
|
||||
original_iat_protect,
|
||||
temporary_protect
|
||||
);
|
||||
emit_set_state(&code, state_address, 2);
|
||||
success_jump = emit_near_jump_placeholder(&code, 0); /* jmp finish */
|
||||
|
||||
entry_failure_offset = code.length;
|
||||
@@ -363,7 +497,8 @@ static BOOL build_remote_hook(
|
||||
emit_call_iat(&code, virtual_protect_iat);
|
||||
|
||||
failure_offset = code.length;
|
||||
emit_restore_iat(
|
||||
emit_set_state(&code, state_address, 3);
|
||||
failure_restore_failure_jump = emit_restore_iat(
|
||||
&code,
|
||||
lstrcmp_iat,
|
||||
original_lstrcmp,
|
||||
@@ -371,7 +506,6 @@ static BOOL build_remote_hook(
|
||||
original_iat_protect,
|
||||
temporary_protect
|
||||
);
|
||||
emit_set_state(&code, state_address, 3);
|
||||
|
||||
success_offset = code.length;
|
||||
emit_bytes(&code, (const BYTE[]){0x31, 0xc0}, 2); /* successful lstrcmpA result */
|
||||
@@ -383,6 +517,8 @@ static BOOL build_remote_hook(
|
||||
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)
|
||||
@@ -394,6 +530,64 @@ static BOOL build_remote_hook(
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
@@ -647,18 +841,78 @@ static BOOL install_world_fix(
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
@@ -667,22 +921,11 @@ static DWORD wait_for_game_and_report(
|
||||
BYTE state = 0;
|
||||
if (read_exact(process, installation->remote_state, &state, sizeof(state)))
|
||||
{
|
||||
if (state == 2)
|
||||
{
|
||||
fprintf(
|
||||
stderr,
|
||||
"PTWORLD: login accepted; world-transition fix activated in memory.\n"
|
||||
reported = report_world_fix_state(
|
||||
status,
|
||||
state,
|
||||
&login_reported
|
||||
);
|
||||
reported = TRUE;
|
||||
}
|
||||
else if (state == 3)
|
||||
{
|
||||
fprintf(
|
||||
stderr,
|
||||
"PTWORLD: login succeeded, but the verified memory fix could not be activated.\n"
|
||||
);
|
||||
reported = TRUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,6 +949,19 @@ static int run_self_test(void)
|
||||
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,
|
||||
@@ -733,6 +989,8 @@ 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;
|
||||
@@ -747,6 +1005,33 @@ int wmain(int argument_count, wchar_t **arguments)
|
||||
{
|
||||
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));
|
||||
@@ -755,6 +1040,8 @@ int wmain(int argument_count, wchar_t **arguments)
|
||||
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(
|
||||
@@ -765,6 +1052,8 @@ int wmain(int argument_count, wchar_t **arguments)
|
||||
) < 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(
|
||||
@@ -780,26 +1069,51 @@ int wmain(int argument_count, wchar_t **arguments)
|
||||
&process
|
||||
))
|
||||
{
|
||||
DWORD error = GetLastError();
|
||||
fprintf(
|
||||
stderr,
|
||||
"PTWORLD: could not start Game.exe (Windows error %lu).\n",
|
||||
(unsigned long)GetLastError()
|
||||
(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))
|
||||
{
|
||||
@@ -807,13 +1121,34 @@ int wmain(int argument_count, wchar_t **arguments)
|
||||
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 if (!install_world_fix(process.hProcess, game_dll_base, &installation))
|
||||
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
|
||||
{
|
||||
@@ -821,9 +1156,24 @@ int wmain(int argument_count, wchar_t **arguments)
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "0.5.9"
|
||||
__version__ = "0.5.10"
|
||||
|
||||
@@ -49,6 +49,10 @@ from .self_update import (
|
||||
rollback_launcher_update,
|
||||
update_check_to_dict,
|
||||
)
|
||||
from .world_fix import (
|
||||
create_world_fix_status_target,
|
||||
read_world_fix_status,
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
@@ -650,6 +654,12 @@ def _play_locked(
|
||||
patterns=("log_*.txt",),
|
||||
)
|
||||
game_started_ns = time.time_ns()
|
||||
world_fix_status_target = None
|
||||
if not dry_run and not publisher_dll:
|
||||
world_fix_status_target = create_world_fix_status_target(
|
||||
paths.game_save,
|
||||
game_started_ns,
|
||||
)
|
||||
try:
|
||||
outcome = run_game(
|
||||
paths,
|
||||
@@ -658,6 +668,7 @@ def _play_locked(
|
||||
runner,
|
||||
use_gamebox=not no_box,
|
||||
use_world_fix=not publisher_dll,
|
||||
world_fix_status_target=world_fix_status_target,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
current_game_log = _current_game_log(
|
||||
@@ -667,12 +678,26 @@ def _play_locked(
|
||||
)
|
||||
if current_game_log is not None and not as_json:
|
||||
_print_interrupted_game_log(current_game_log)
|
||||
world_fix_status = read_world_fix_status(
|
||||
world_fix_status_target,
|
||||
game_started_ns,
|
||||
)
|
||||
if world_fix_status is not None and not as_json:
|
||||
print(
|
||||
f"World-fix status: {str(world_fix_status['status'])} "
|
||||
f"({str(world_fix_status['path'])})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
current_game_log = _current_game_log(
|
||||
native_log_directory,
|
||||
native_log_baseline,
|
||||
game_started_ns,
|
||||
)
|
||||
world_fix_status = read_world_fix_status(
|
||||
world_fix_status_target,
|
||||
game_started_ns,
|
||||
)
|
||||
_emit(
|
||||
{
|
||||
"ok": outcome.returncode == 0,
|
||||
@@ -682,6 +707,7 @@ def _play_locked(
|
||||
"setup_commands": [asdict(result) for result in setup_results],
|
||||
"game": asdict(outcome),
|
||||
"current_game_log": current_game_log,
|
||||
"world_fix": world_fix_status,
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
@@ -712,7 +738,17 @@ def _current_game_log(
|
||||
"import_scene_threw": analysis.import_scene_threw,
|
||||
"failed_import_scenes": analysis.failed_import_scenes,
|
||||
"stage_without_new_stage": analysis.stage_without_new_stage,
|
||||
"stage_without_new_stage_events": [
|
||||
event.to_dict()
|
||||
for event in analysis.events
|
||||
if event.kind == "stage_without_new_stage"
|
||||
],
|
||||
"scene_imports": {
|
||||
name: [scene.to_dict() for scene in imports]
|
||||
for name, imports in sorted(analysis.scenes.items())
|
||||
},
|
||||
"failed_file_count": len(analysis.failed_files),
|
||||
"failed_files": [event.to_dict() for event in analysis.failed_files[:64]],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -16,7 +16,11 @@ 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, resolve_world_fix_helper
|
||||
from .world_fix import (
|
||||
WorldFixError,
|
||||
WorldFixStatusTarget,
|
||||
resolve_world_fix_helper,
|
||||
)
|
||||
|
||||
|
||||
WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe"
|
||||
@@ -252,6 +256,7 @@ def run_game(
|
||||
*,
|
||||
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,
|
||||
@@ -273,6 +278,10 @@ def run_game(
|
||||
]
|
||||
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:
|
||||
@@ -283,6 +292,10 @@ def run_game(
|
||||
]
|
||||
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,
|
||||
@@ -301,6 +314,10 @@ def run_game(
|
||||
]
|
||||
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,
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PureWindowsPath
|
||||
|
||||
|
||||
V21_BINARY_HASHES = {
|
||||
@@ -13,8 +16,16 @@ V21_BINARY_HASHES = {
|
||||
WORLD_FIX_HELPER_FILENAME = "pt-world-fix.exe"
|
||||
# Filled from the reproducible stripped PE after the native helper is built.
|
||||
WORLD_FIX_HELPER_SHA256 = (
|
||||
"d5bdf655f62dae429eff627cf8960f670ec59c12a9759dcd14342786f53e4019"
|
||||
"a7a17d29ef4c11f9603790f0110a4d6823b91cce03f70e466b7e0bdbd6fa313e"
|
||||
)
|
||||
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):
|
||||
@@ -58,6 +69,145 @@ def resolve_world_fix_helper(
|
||||
return candidate
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
|
||||
Reference in New Issue
Block a user