Added asset checks and fixes for missing data in game client.

This commit is contained in:
2026-09-05 21:52:41 +02:00
parent cccce05a99
commit f0d631be66
7 changed files with 1011 additions and 211 deletions

View File

@@ -44,7 +44,8 @@ PATCHED_V21_GAME_DLL_SHA256 = (
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
)
RUNTIME_VIEW_CONFIGURATION_VERSION = 2
RUNTIME_VIEW_CONFIGURATION_VERSION = 3
WRITABLE_ASSET_SUFFIXES = frozenset({".grx_img"})
WINDOWED_GAME_CONFIG_VALUES = (
(b"windowed", b"Windowed", b"1"),
(b"startmaximized", b"StartMaximized", b"False"),
@@ -68,6 +69,15 @@ class RuntimeFileIdentity:
ctime_ns: int
@dataclass(frozen=True)
class WritableAssetInventory:
"""Source identities for asset formats the client is known to modify."""
files: tuple[RuntimeFileIdentity, ...]
bytes_total: int
fingerprint: str
@dataclass(frozen=True)
class OverlayResult:
source_game: str
@@ -82,22 +92,9 @@ class OverlayResult:
runtime_scene_identity: RuntimeFileIdentity
created_at: str
links_created: int
@dataclass(frozen=True)
class LegacyOverlayResult:
"""Version-one runtime manifest retained only for a one-time fast migration."""
source_game: str
runtime_view: str
source_scene_sha256: str
runtime_scene_sha256: str
scene_action: str
binary_action: str
game_exe_sha256: str
game_dll_sha256: str
created_at: str
links_created: int
writable_asset_count: int = 0
writable_asset_bytes: int = 0
writable_asset_source_fingerprint: str = ""
@dataclass(frozen=True)
@@ -173,14 +170,21 @@ def prepare_runtime_view(
paths: AppPaths,
source_game: Path,
) -> OverlayResult:
"""Build a symlink view and keep the launcher-managed source read-only."""
"""Build a linked runtime view with private copies for writable asset caches."""
source = source_game.expanduser().resolve(strict=True)
_validate_game_tree(source)
_validate_runtime_target(paths)
game_save = _prepare_persistent_save(paths, source)
game_config = _prepare_persistent_game_config(paths, source)
reused = _reuse_runtime_view(paths, source, game_save, game_config)
writable_assets = _writable_asset_inventory(source / "game")
reused = _reuse_runtime_view(
paths,
source,
game_save,
game_config,
writable_assets,
)
if reused is not None:
return reused
input_identities = _runtime_input_identities(paths, source)
@@ -191,27 +195,11 @@ def prepare_runtime_view(
binary_action,
game_exe_source,
game_dll_source,
source_binary_hashes,
_source_binary_hashes,
) = _runtime_game_binary_profile(
paths,
source,
)
legacy_reuse = _reuse_legacy_runtime_view(
paths,
source,
inspection,
action,
game_save,
game_config,
binary_action,
game_exe_source,
game_dll_source,
source_binary_hashes,
input_identities,
)
if legacy_reuse is not None:
return legacy_reuse
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}"
links_created = 0
@@ -242,16 +230,12 @@ def prepare_runtime_view(
game_source = source / "game"
game_target = build / "game"
game_target.mkdir()
links_created += _link_level(game_source, game_target, except_names={"maps"})
maps_source = game_source / "maps"
maps_target = game_target / "maps"
maps_target.mkdir()
links_created += _link_level(
maps_source,
maps_target,
except_names={"WhisperingVale.rovug"},
links_created += _build_runtime_game_tree(
game_source,
game_target,
writable_assets,
)
runtime_scene = maps_target / "WhisperingVale.rovug"
runtime_scene = build / WHISPERING_VALE_RELATIVE
if action == "normalize-known-profile":
normalization = normalize_wrapped_assbin_textures(
source_scene,
@@ -265,6 +249,10 @@ def prepare_runtime_view(
raise OverlayError(
"game files changed while the managed runtime view was being prepared"
)
if _writable_asset_inventory(game_source) != writable_assets:
raise OverlayError(
"writable game assets changed while the managed runtime view was being prepared"
)
if paths.runtime_view.exists() or paths.runtime_view.is_symlink():
paths.runtime_view.replace(backup)
@@ -291,6 +279,9 @@ def prepare_runtime_view(
"runtime-scene",
paths.runtime_view / WHISPERING_VALE_RELATIVE,
),
writable_asset_count=len(writable_assets.files),
writable_asset_bytes=writable_assets.bytes_total,
writable_asset_source_fingerprint=writable_assets.fingerprint,
created_at=datetime.now().astimezone().isoformat(),
links_created=links_created,
)
@@ -477,6 +468,96 @@ def _link_level(source: Path, target: Path, *, except_names: set[str]) -> int:
return count
def _writable_asset_inventory(game_source: Path) -> WritableAssetInventory:
"""Collect only formats observed writing cache headers during game sessions."""
files: list[RuntimeFileIdentity] = []
for directory, directory_names, file_names in os.walk(game_source, followlinks=False):
parent = Path(directory)
directory_names[:] = sorted(
name for name in directory_names if not (parent / name).is_symlink()
)
for name in sorted(file_names):
path = parent / name
if path.suffix.casefold() not in WRITABLE_ASSET_SUFFIXES:
continue
if path.is_symlink() or not path.is_file():
raise OverlayError(f"writable game asset is not a regular file: {path}")
relative = path.relative_to(game_source).as_posix()
files.append(
_runtime_file_identity(relative, path, follow_symlinks=False)
)
files.sort(key=lambda item: item.name.casefold())
digest = hashlib.sha256()
for identity in files:
digest.update(identity.name.encode("utf-8", errors="surrogateescape"))
digest.update(b"\0")
for value in (
identity.device,
identity.inode,
identity.mode,
identity.size,
identity.mtime_ns,
identity.ctime_ns,
):
digest.update(str(value).encode("ascii"))
digest.update(b"\0")
return WritableAssetInventory(
files=tuple(files),
bytes_total=sum(identity.size for identity in files),
fingerprint=digest.hexdigest(),
)
def _build_runtime_game_tree(
game_source: Path,
game_target: Path,
inventory: WritableAssetInventory,
) -> int:
"""Build a sparse writable view while linking every immutable subtree."""
writable_files = {Path(identity.name) for identity in inventory.files}
materialized_files = writable_files | {WHISPERING_VALE_RELATIVE.relative_to("game")}
materialized_directories = _materialized_directories(materialized_files)
def build_level(source: Path, target: Path, relative_parent: Path) -> int:
links = 0
for child in sorted(source.iterdir(), key=lambda path: path.name.casefold()):
relative = relative_parent / child.name
destination = target / child.name
if relative == WHISPERING_VALE_RELATIVE.relative_to("game"):
continue
if relative in writable_files:
if child.is_symlink() or not child.is_file():
raise OverlayError(f"writable game asset is not a regular file: {child}")
shutil.copy2(child, destination)
destination.chmod(destination.stat().st_mode | 0o200)
continue
if (
relative in materialized_directories
and child.is_dir()
and not child.is_symlink()
):
destination.mkdir()
links += build_level(child, destination, relative)
continue
destination.symlink_to(child, target_is_directory=child.is_dir())
links += 1
return links
return build_level(game_source, game_target, Path())
def _materialized_directories(files: set[Path]) -> set[Path]:
directories: set[Path] = set()
for path in files:
parent = path.parent
while parent != Path("."):
directories.add(parent)
parent = parent.parent
return directories
def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path:
"""Migrate game-created settings and logs before replacing the runtime view."""
@@ -687,6 +768,7 @@ def _runtime_input_identities(
def _matching_cached_runtime_result(
paths: AppPaths,
source: Path,
writable_assets: WritableAssetInventory | None = None,
) -> OverlayResult | None:
result = _read_overlay_result(paths.runtime_manifest)
if result is None:
@@ -695,6 +777,17 @@ def _matching_cached_runtime_result(
return None
if result.runtime_view != str(paths.runtime_view):
return None
if writable_assets is None:
try:
writable_assets = _writable_asset_inventory(source / "game")
except OSError:
return None
if (
result.writable_asset_count != len(writable_assets.files)
or result.writable_asset_bytes != writable_assets.bytes_total
or result.writable_asset_source_fingerprint != writable_assets.fingerprint
):
return None
try:
current_identities = _runtime_input_identities(paths, source)
except OSError:
@@ -721,96 +814,14 @@ def _cached_runtime_compatibility(
)
def _reuse_legacy_runtime_view(
paths: AppPaths,
source: Path,
inspection: SceneInspection,
scene_action: str,
game_save: Path,
game_config: Path,
binary_action: str,
game_exe_source: Path,
game_dll_source: Path,
source_binary_hashes: Mapping[str, str],
input_identities: tuple[RuntimeFileIdentity, ...],
) -> OverlayResult | None:
"""Upgrade an unchanged v1 view after its original hash checks pass once."""
legacy = _read_legacy_overlay_result(paths.runtime_manifest)
if legacy is None:
return None
if legacy.source_game != str(source):
return None
if legacy.runtime_view != str(paths.runtime_view):
return None
if legacy.source_scene_sha256 != inspection.file_sha256:
return None
if legacy.scene_action != scene_action or legacy.binary_action != binary_action:
return None
if legacy.game_exe_sha256 != source_binary_hashes.get("Game.exe"):
return None
if legacy.game_dll_sha256 != source_binary_hashes.get("game.dll"):
return None
_migrate_runtime_game_config_link(
paths.runtime_view / "game.ini",
source / "game.ini",
game_config,
)
if not _runtime_layout_ready(
paths,
source,
game_save,
game_config,
game_exe_source,
game_dll_source,
legacy.links_created,
):
return None
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
try:
if _sha256(runtime_scene) != legacy.runtime_scene_sha256:
return None
if _runtime_input_identities(paths, source) != input_identities:
raise OverlayError(
"game files changed while the legacy runtime view was being verified"
)
runtime_scene_identity = _runtime_file_identity(
"runtime-scene",
runtime_scene,
)
except OSError:
return None
result = OverlayResult(
source_game=legacy.source_game,
runtime_view=legacy.runtime_view,
source_scene_sha256=legacy.source_scene_sha256,
runtime_scene_sha256=legacy.runtime_scene_sha256,
scene_action=legacy.scene_action,
binary_action=legacy.binary_action,
game_exe_sha256=legacy.game_exe_sha256,
game_dll_sha256=legacy.game_dll_sha256,
input_identities=input_identities,
runtime_scene_identity=runtime_scene_identity,
created_at=legacy.created_at,
links_created=legacy.links_created,
)
_write_json(
paths.runtime_manifest,
{
"configuration_version": RUNTIME_VIEW_CONFIGURATION_VERSION,
**asdict(result),
},
)
return result
def _reuse_runtime_view(
paths: AppPaths,
source: Path,
game_save: Path,
game_config: Path,
writable_assets: WritableAssetInventory,
) -> OverlayResult | None:
result = _matching_cached_runtime_result(paths, source)
result = _matching_cached_runtime_result(paths, source, writable_assets)
if result is None:
return None
game_exe_source = source / "Game.exe"
@@ -828,6 +839,7 @@ def _reuse_runtime_view(
game_exe_source,
game_dll_source,
result.links_created,
writable_assets,
):
return None
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
@@ -852,6 +864,7 @@ def _runtime_layout_ready(
game_exe_source: Path,
game_dll_source: Path,
expected_links: int,
writable_assets: WritableAssetInventory,
) -> bool:
runtime = paths.runtime_view
if runtime.is_symlink() or not runtime.is_dir():
@@ -888,35 +901,14 @@ def _runtime_layout_ready(
):
return False
game_source = source / "game"
game_target = runtime / "game"
if game_target.is_symlink() or not game_target.is_dir():
return False
game_ready, game_links = _linked_level_ready(
game_source,
game_target,
except_names={"maps"},
special_names={"maps"},
game_ready, game_links = _runtime_game_tree_ready(
source / "game",
runtime / "game",
writable_assets,
)
if not game_ready:
return False
maps_source = game_source / "maps"
maps_target = game_target / "maps"
if maps_target.is_symlink() or not maps_target.is_dir():
return False
maps_ready, maps_links = _linked_level_ready(
maps_source,
maps_target,
except_names={"WhisperingVale.rovug"},
special_names={"WhisperingVale.rovug"},
)
if not maps_ready:
return False
runtime_scene = maps_target / "WhisperingVale.rovug"
if runtime_scene.is_symlink() or not runtime_scene.is_file():
return False
actual_links = top_level_links + game_links + maps_links + 2
actual_links = top_level_links + game_links + 2
if game_exe_source != source / "Game.exe":
actual_links += 1
if game_dll_source != source / "game.dll":
@@ -924,6 +916,66 @@ def _runtime_layout_ready(
return actual_links == expected_links
def _runtime_game_tree_ready(
game_source: Path,
game_target: Path,
inventory: WritableAssetInventory,
) -> tuple[bool, int]:
if game_target.is_symlink() or not game_target.is_dir():
return False, 0
writable_files = {
Path(identity.name): identity.size
for identity in inventory.files
}
protected_scene = WHISPERING_VALE_RELATIVE.relative_to("game")
materialized_directories = _materialized_directories(
set(writable_files) | {protected_scene}
)
def check_level(source: Path, target: Path, relative_parent: Path) -> tuple[bool, int]:
try:
source_children = {child.name: child for child in source.iterdir()}
target_names = {child.name for child in target.iterdir()}
except OSError:
return False, 0
if target_names != set(source_children):
return False, 0
links = 0
for name, child in source_children.items():
relative = relative_parent / name
runtime_child = target / name
if relative == protected_scene:
if runtime_child.is_symlink() or not runtime_child.is_file():
return False, 0
continue
writable_size = writable_files.get(relative)
if writable_size is not None:
try:
if (
runtime_child.is_symlink()
or not runtime_child.is_file()
or runtime_child.stat().st_size != writable_size
):
return False, 0
except OSError:
return False, 0
continue
if relative in materialized_directories:
if runtime_child.is_symlink() or not runtime_child.is_dir():
return False, 0
ready, child_links = check_level(child, runtime_child, relative)
if not ready:
return False, 0
links += child_links
continue
if not _symlink_points_to(runtime_child, child):
return False, 0
links += 1
return True, links
return check_level(game_source, game_target, Path())
def _linked_level_ready(
source: Path,
target: Path,
@@ -996,6 +1048,7 @@ def _read_overlay_result(path: Path) -> OverlayResult | None:
"binary_action",
"game_exe_sha256",
"game_dll_sha256",
"writable_asset_source_fingerprint",
"created_at",
)
if not all(isinstance(value.get(name), str) for name in string_fields):
@@ -1005,6 +1058,17 @@ def _read_overlay_result(path: Path) -> OverlayResult | None:
return None
if links_created < 0:
return None
writable_asset_count = value.get("writable_asset_count")
writable_asset_bytes = value.get("writable_asset_bytes")
if (
isinstance(writable_asset_count, bool)
or not isinstance(writable_asset_count, int)
or writable_asset_count < 0
or isinstance(writable_asset_bytes, bool)
or not isinstance(writable_asset_bytes, int)
or writable_asset_bytes < 0
):
return None
input_values = value.get("input_identities")
if not isinstance(input_values, list) or not input_values:
return None
@@ -1040,6 +1104,11 @@ def _read_overlay_result(path: Path) -> OverlayResult | None:
game_dll_sha256=value["game_dll_sha256"],
input_identities=tuple(input_identities),
runtime_scene_identity=runtime_scene_identity,
writable_asset_count=writable_asset_count,
writable_asset_bytes=writable_asset_bytes,
writable_asset_source_fingerprint=value[
"writable_asset_source_fingerprint"
],
created_at=value["created_at"],
links_created=links_created,
)
@@ -1072,47 +1141,6 @@ def _parse_runtime_file_identity(value: object) -> RuntimeFileIdentity | None:
)
def _read_legacy_overlay_result(path: Path) -> LegacyOverlayResult | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not isinstance(value, dict) or value.get("configuration_version") != 1:
return None
string_fields = (
"source_game",
"runtime_view",
"source_scene_sha256",
"runtime_scene_sha256",
"scene_action",
"binary_action",
"game_exe_sha256",
"game_dll_sha256",
"created_at",
)
if not all(isinstance(value.get(name), str) for name in string_fields):
return None
links_created = value.get("links_created")
if (
isinstance(links_created, bool)
or not isinstance(links_created, int)
or links_created < 0
):
return None
return LegacyOverlayResult(
source_game=value["source_game"],
runtime_view=value["runtime_view"],
source_scene_sha256=value["source_scene_sha256"],
runtime_scene_sha256=value["runtime_scene_sha256"],
scene_action=value["scene_action"],
binary_action=value["binary_action"],
game_exe_sha256=value["game_exe_sha256"],
game_dll_sha256=value["game_dll_sha256"],
created_at=value["created_at"],
links_created=links_created,
)
def _runtime_game_binary_profile(
paths: AppPaths,
source: Path,