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 cbe14c0..331de1f 100755 Binary files a/ptlinux/native/pt-world-fix.exe and b/ptlinux/native/pt-world-fix.exe differ 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: