From 010fe14ab46748e1c32cbcf4717f8675a8f39e46 Mon Sep 17 00:00:00 2001 From: m4ko Date: Fri, 28 Aug 2026 20:56:26 +0200 Subject: [PATCH] Added more diagnostics to the native world fixer and py logs system. --- native/pt_world_fix.c | 416 +++++++++++++++++++++++++++++--- ptlinux/__init__.py | 2 +- ptlinux/cli.py | 36 +++ ptlinux/native/pt-world-fix.exe | Bin 62464 -> 73728 bytes ptlinux/runtime.py | 19 +- ptlinux/world_fix.py | 154 +++++++++++- 6 files changed, 590 insertions(+), 37 deletions(-) diff --git a/native/pt_world_fix.c b/native/pt_world_fix.c index 95d0d33..62ccd87 100644 --- a/native/pt_world_fix.c +++ b/native/pt_world_fix.c @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -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 = TRUE; - } - else if (state == 3) - { - fprintf( - stderr, - "PTWORLD: login succeeded, but the verified memory fix could not be activated.\n" - ); - reported = TRUE; - } + reported = report_world_fix_state( + status, + state, + &login_reported + ); } } } @@ -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,23 +1121,59 @@ int wmain(int argument_count, wchar_t **arguments) stderr, "PTWORLD: game.dll memory did not match V21.75.83; continuing pristine.\n" ); - } - else if (!install_world_fix(process.hProcess, game_dll_base, &installation)) - { - fprintf( - stderr, - "PTWORLD: the post-login hook could not be armed; 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 { - fprintf( - stderr, - "PTWORLD: publisher game.dll verified; post-login world fix armed.\n" + 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); + 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/__init__.py b/ptlinux/__init__.py index edb01db..3ea0fa0 100644 --- a/ptlinux/__init__.py +++ b/ptlinux/__init__.py @@ -4,4 +4,4 @@ from .paths import AppPaths __all__ = ["AppPaths", "__version__"] -__version__ = "0.5.9" +__version__ = "0.5.10" diff --git a/ptlinux/cli.py b/ptlinux/cli.py index aa4d0fb..541c295 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -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]], } diff --git a/ptlinux/native/pt-world-fix.exe b/ptlinux/native/pt-world-fix.exe index cbe14c0abbf8775624abfb973513a03a9bc898e7..331de1f5bece78ad6c7901a4348f9283f3b98514 100755 GIT binary patch delta 25145 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 C6FP?g diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index b60c861..741c695 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -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, diff --git a/ptlinux/world_fix.py b/ptlinux/world_fix.py index 0b5d7d4..0869e09 100644 --- a/ptlinux/world_fix.py +++ b/ptlinux/world_fix.py @@ -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: