Added asset checks and fixes for missing data in game client.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user