6 Commits

Author SHA1 Message Date
be7891f066 Pushed new release: v1.0.8 2026-09-19 12:49:03 +02:00
6a508476b9 Added better game crash diagnostics. 2026-09-18 21:36:12 +02:00
9ef0eff2f5 Fixed a issue with resource exhaustion for directx. 2026-09-15 21:48:27 +02:00
06469a0ad3 Updated to version: 1.0.5 2026-09-15 21:29:05 +02:00
f7ceba2b9b Added crash dumps class. 2026-09-15 21:23:10 +02:00
67d89b40d5 Updated telemetry to support game dumps. 2026-09-15 21:15:39 +02:00
23 changed files with 1267 additions and 139 deletions

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux
Version 0.9.11 installs, updates, repairs, and launches PristonTale EU through a
Version 1.0.8 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not
included in this archive.
@@ -15,11 +15,11 @@ Then run:
```text
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
openssl dgst -sha256 -verify launcher-update-public.pem \
-signature pristontale-eu-linux-installer-v0-9-11.tar.gz.sig \
pristontale-eu-linux-installer-v0-9-11.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-9-11.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-9-11.tar.gz
cd pristontale-eu-linux-installer-v0-9-11
-signature pristontale-eu-linux-installer-v1-0-8.tar.gz.sig \
pristontale-eu-linux-installer-v1-0-8.tar.gz
sha256sum -c pristontale-eu-linux-installer-v1-0-8.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v1-0-8.tar.gz
cd pristontale-eu-linux-installer-v1-0-8
./install.sh
```

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "PTEU-Linux-Launcher"
version = "0.9.11"
version = "1.0.8"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11"
dependencies = []

View File

@@ -8,10 +8,12 @@ import os
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
if __package__:
from .repository_paths import ensure_project_imports
else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, build_release # noqa: E402

View File

@@ -7,10 +7,12 @@ import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
if __package__:
from .repository_paths import ensure_project_imports
else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
generate_manifest,

View File

@@ -11,10 +11,12 @@ import sys
from typing import Mapping
from urllib.parse import urlsplit
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
if __package__:
from .repository_paths import PROJECT_ROOT, ensure_project_imports
else:
from repository_paths import PROJECT_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
_parse_timestamp,

View File

@@ -9,10 +9,12 @@ import os
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
if __package__:
from .repository_paths import ensure_project_imports
else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from ptlinux.self_update import LauncherUpdateError, parse_update_manifest # noqa: E402
from ptlinux.update_resolver import ( # noqa: E402
ReleaseModelError,

View File

@@ -38,7 +38,7 @@ cleanup()
trap cleanup EXIT
RELEASE_MODEL="$(
"$PYTHON_BIN" "${SCRIPT_ROOT}/../ptlinux/update_resolver.py" inspect-tag "$TAG"
"$PYTHON_BIN" "${SCRIPT_ROOT}/../src/ptlinux/update_resolver.py" inspect-tag "$TAG"
)"
if [[ "$(jq -r '.version' <<< "$RELEASE_MODEL")" != "$VERSION" \
|| "$(jq -r '.channel' <<< "$RELEASE_MODEL")" != "$CHANNEL" \

View File

@@ -7,8 +7,8 @@ usage()
cat <<'EOF'
Usage: ./scripts/release.sh CHANNEL VERSION [OPTIONS]
Build a verified local release package in dist/vVERSION/. This command does not
commit, tag, push, create a Gitea release, or upload files.
Build a verified local release package in .artifacts/releases/vVERSION/. This
command does not commit, tag, push, create a Gitea release, or upload files.
Channels:
stable
@@ -22,7 +22,8 @@ Options:
--editor Edit the final notes with ${EDITOR:-nano}
--dry-run Build and verify in a temporary directory only
--skip-tests Skip configured local preflight checks
--signing-key PATH Use PATH instead of the local release signing key
--signing-key PATH Use an external private signing key at PATH
--output-directory PATH Write the version package to PATH
-h, --help Show this help
EOF
}
@@ -68,6 +69,7 @@ OPEN_EDITOR=0
DRY_RUN=0
SKIP_TESTS=0
SIGNING_KEY="${PTLINUX_SIGNING_KEY:-}"
REQUESTED_OUTPUT_DIRECTORY=""
while [[ $# -gt 0 ]]
do
@@ -107,6 +109,11 @@ do
SIGNING_KEY="$2"
shift 2
;;
--output-directory)
[[ $# -ge 2 ]] || { printf '%s\n' '--output-directory requires a path.' >&2; exit 2; }
REQUESTED_OUTPUT_DIRECTORY="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -145,6 +152,7 @@ then
exit 1
fi
cd -- "$PROJECT_ROOT"
export PYTHONPATH="${PROJECT_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}"
"$PYTHON_BIN" -m ptlinux.update_resolver validate "$CHANNEL" "$VERSION"
TAG="v${VERSION}"
@@ -171,6 +179,17 @@ cleanup()
}
trap cleanup EXIT
NOTES_PATH="${TEMPORARY_ROOT}/release-notes.md"
if [[ "$DRY_RUN" -eq 1 && "$CHANGELOG_ONLY" -eq 0 ]]
then
OUTPUT_CANDIDATE="${TEMPORARY_ROOT}/releases/${TAG}"
else
OUTPUT_CANDIDATE="${REQUESTED_OUTPUT_DIRECTORY:-${PROJECT_ROOT}/.artifacts/releases/${TAG}}"
fi
OUTPUT_DIRECTORY="$(
"$PYTHON_BIN" -c \
'import sys; from pathlib import Path; from scripts.release_lib import _safe_output_directory; print(_safe_output_directory(Path(sys.argv[1])))' \
"$OUTPUT_CANDIDATE"
)"
if [[ -z "$DESCRIPTION" && -z "$DESCRIPTION_FILE" && -t 0 && "$CHANGELOG_ONLY" -eq 0 ]]
then
@@ -237,14 +256,33 @@ printf '%s\n' '--------------------------------------------------'
if [[ "$CHANGELOG_ONLY" -eq 1 ]]
then
NOTES_OUTPUT_DIRECTORY="${PROJECT_ROOT}/dist/${TAG}"
mkdir -p -- "$NOTES_OUTPUT_DIRECTORY"
cp -- "$NOTES_PATH" "${NOTES_OUTPUT_DIRECTORY}/release-notes.md"
mkdir -p -- "$OUTPUT_DIRECTORY"
cp -- "$NOTES_PATH" "${OUTPUT_DIRECTORY}/release-notes.md"
printf 'Release notes written to %s\n' \
"${NOTES_OUTPUT_DIRECTORY}/release-notes.md"
"${OUTPUT_DIRECTORY}/release-notes.md"
exit 0
fi
if [[ -z "$SIGNING_KEY" ]]
then
printf '%s\n' 'A release signing key is required.' >&2
printf '%s\n' \
'Use --signing-key PATH or set PTLINUX_SIGNING_KEY to a path outside the repository.' >&2
exit 1
fi
SIGNING_KEY="$(
"$PYTHON_BIN" -c \
'import sys
from pathlib import Path
from scripts.release_lib import ReleaseToolError, _safe_signing_key_path
try:
print(_safe_signing_key_path(Path(sys.argv[1]), output_directory=Path(sys.argv[2])))
except ReleaseToolError as exc:
print(f"Invalid release signing key: {exc}", file=sys.stderr)
raise SystemExit(2)' \
"$SIGNING_KEY" "$OUTPUT_DIRECTORY"
)"
if [[ "$SKIP_TESTS" -ne 1 ]]
then
mapfile -t PREFLIGHT_COMMANDS < <(
@@ -261,11 +299,6 @@ then
else
printf '%s\n' 'Skipping local preflight checks by request.'
fi
if [[ -z "$SIGNING_KEY" ]]
then
SIGNING_KEY="${PROJECT_ROOT}/release-keys/launcher-update-private.pem"
fi
if [[ ! -f "$SIGNING_KEY" || -L "$SIGNING_KEY" ]]
then
printf 'Release signing key is missing: %s\n' "$SIGNING_KEY" >&2
@@ -273,13 +306,6 @@ then
exit 1
fi
if [[ "$DRY_RUN" -eq 1 ]]
then
OUTPUT_DIRECTORY="${TEMPORARY_ROOT}/dist/${TAG}"
else
OUTPUT_DIRECTORY="${PROJECT_ROOT}/dist/${TAG}"
fi
SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \
RELEASE_COMMIT="$COMMIT" \
RELEASE_TAG="$TAG" \
@@ -297,7 +323,7 @@ REQUIRE_RELEASE_SIGNATURE=1 \
if [[ "$DRY_RUN" -eq 1 ]]
then
printf '%s\n' \
'Dry run complete. The release built and verified in a temporary directory; dist/ was not changed.'
'Dry run complete. The release built and verified in a temporary directory; .artifacts/ was not changed.'
exit 0
fi

View File

@@ -7,6 +7,7 @@ from datetime import datetime, timezone
import fnmatch
import gzip
import hashlib
import io
import json
import os
from pathlib import Path, PurePosixPath
@@ -20,10 +21,14 @@ import tempfile
from typing import BinaryIO, Mapping, Sequence
from urllib.parse import urlsplit
from scripts.repository_paths import (
ARTIFACTS_ROOT,
PROJECT_ROOT,
RELEASE_ARTIFACTS_ROOT,
ensure_project_imports,
)
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
ensure_project_imports()
CONFIG_PATH = PROJECT_ROOT / "release/config.json"
READ_SIZE = 256 * 1024
MAX_ARCHIVE_BYTES = 32 * 1024 * 1024
@@ -42,6 +47,9 @@ _PROJECT_VERSION_PATTERN = re.compile(
r'^version = "([0-9]+\.[0-9]+\.[0-9]+(?:-(?:beta|rc)\.[0-9]+)?)"$',
re.MULTILINE,
)
_LEGACY_VERSION_PATTERN = re.compile(
r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z"
)
class ReleaseToolError(RuntimeError):
@@ -202,8 +210,11 @@ def build_release(
_validate_repository_url(repository)
release_notes = _read_release_notes(release_notes_file)
output = _safe_output_directory(output_directory)
if signing_key is not None and signing_key.expanduser().resolve().is_relative_to(output):
raise ReleaseToolError("the private signing key cannot be stored in release output")
if signing_key is not None:
signing_key = _safe_signing_key_path(
signing_key,
output_directory=output,
)
_clean_output_directory(output)
archive_prefix = _required_string(config, "archive_prefix")
archive_name = f"{archive_prefix}-{version}.tar.gz"
@@ -264,7 +275,10 @@ def build_release(
verify_signature(
manifest,
signature,
PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
_payload_source_for_destination(
config,
PurePosixPath("ptlinux/launcher-update-public.pem"),
),
)
artifacts.append(signature)
elif require_signature:
@@ -272,6 +286,112 @@ def build_release(
return tuple(artifacts)
def build_legacy_launcher_update(
*,
version: str,
output_directory: Path,
repository_url: str,
minimum_updater_version: str,
signing_key: Path | None,
require_signature: bool = False,
source_date_epoch: int,
) -> tuple[Path, ...]:
"""Build the schema-1 update artifacts consumed by deployed launchers.
The names, wrapped archive layout, minimal ``release.json``, and ``stable.json``
shape are compatibility contracts. Payload selection and archive serialization
live here so the historical ``tools/build_launcher_update.py`` entry point can
remain a forwarding wrapper.
"""
_validate_legacy_version(version, "release version")
_validate_legacy_version(minimum_updater_version, "minimum updater version")
config = load_config()
current_version = source_version(config)
if current_version != version:
raise ReleaseToolError(
f"requested version {version} does not match ptlinux.__version__ "
f"{current_version}"
)
_validate_repository_url(repository_url)
if require_signature and signing_key is None:
raise ReleaseToolError("a signing key is required for a production release")
if source_date_epoch < 0:
raise ReleaseToolError("source date epoch cannot be negative")
output = output_directory.expanduser().resolve()
if signing_key is not None:
signing_key = _safe_signing_key_path(
signing_key,
output_directory=output,
)
output.mkdir(parents=True, exist_ok=True)
archive_root = (
f"{_required_string(config, 'legacy_archive_root_prefix')}{version}"
)
archive = output / f"{archive_root}.tar.gz"
archive_temporary = output / f".{archive.name}.{os.getpid()}.tmp"
archive_temporary.unlink(missing_ok=True)
try:
_write_legacy_launcher_archive(
archive_temporary,
archive_root=archive_root,
version=version,
mtime=source_date_epoch,
payload=collect_payload(config),
)
archive_temporary.chmod(0o644)
archive_temporary.replace(archive)
except BaseException:
archive_temporary.unlink(missing_ok=True)
raise
digest = sha256_file(archive)
checksum = archive.with_suffix(archive.suffix + ".sha256")
_write_atomic(checksum, f"{digest} {archive.name}\n".encode("ascii"))
repository = repository_url.rstrip("/")
manifest = {
"archive_sha256": digest,
"archive_size": archive.stat().st_size,
"archive_url": f"{repository}/releases/download/v{version}/{archive.name}",
"channel": "stable",
"minimum_updater_version": minimum_updater_version,
"notes_url": f"{repository}/releases/tag/v{version}",
"published_at": _timestamp_from_epoch(source_date_epoch),
"schema": 1,
"version": version,
}
manifest_path = output / "stable.json"
manifest_bytes = (
json.dumps(manifest, indent=2, sort_keys=True, separators=(",", ": ")) + "\n"
).encode("utf-8")
_write_atomic(manifest_path, manifest_bytes)
signature_path = output / "stable.json.sig"
artifacts: list[Path] = [archive, checksum, manifest_path]
if signing_key is not None:
sign_file(manifest_path, signature_path, signing_key)
public_key = _payload_source_for_destination(
config,
PurePosixPath("ptlinux/launcher-update-public.pem"),
)
try:
verify_signature(manifest_path, signature_path, public_key)
except ReleaseToolError as exc:
signature_path.unlink(missing_ok=True)
raise ReleaseToolError(
"the signing key does not match ptlinux/launcher-update-public.pem"
) from exc
artifacts.append(signature_path)
else:
signature_path.unlink(missing_ok=True)
print(
"Warning: stable.json was not signed; set PTLINUX_SIGNING_KEY for a "
"publishable release.",
file=sys.stderr,
)
return tuple(artifacts)
def stage_release(
destination: Path,
config: Mapping[str, object],
@@ -307,6 +427,38 @@ def stage_release(
payload.append(
PayloadFile(PurePosixPath("release.json"), None, release_metadata, 0o644)
)
_stage_payload_files(destination, payload)
_require_staged_files(destination, config)
def stage_runtime_payload(
destination: Path,
config: Mapping[str, object] | None = None,
*,
source_root: Path | None = None,
) -> None:
"""Stage the runtime files shared by installers and launcher updates.
`release.json` is generated by the caller because bootstrap installers and
signed launcher updates attach different source metadata to that file.
"""
release_config = load_config() if config is None else config
_stage_payload_files(
destination,
collect_payload(release_config, source_root=source_root),
)
_require_staged_files(
destination,
release_config,
allowed_missing={"release.json"},
)
def _stage_payload_files(
destination: Path,
payload: Sequence[PayloadFile],
) -> None:
seen: set[PurePosixPath] = set()
for item in sorted(payload, key=lambda entry: entry.relative_path.as_posix()):
if item.relative_path in seen:
@@ -324,12 +476,27 @@ def stage_release(
for directory in (destination, *destination.rglob("*")):
if directory.is_dir():
directory.chmod(0o755)
def _require_staged_files(
destination: Path,
config: Mapping[str, object],
*,
allowed_missing: set[str] | None = None,
) -> None:
missing_allowed = set() if allowed_missing is None else allowed_missing
for required in _string_list(config.get("required_files"), "required_files"):
if required in missing_allowed:
continue
if not (destination / required).is_file():
raise ReleaseToolError(f"staging tree is missing required file: {required}")
def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
def collect_payload(
config: Mapping[str, object],
*,
source_root: Path | None = None,
) -> list[PayloadFile]:
payload_config = config.get("payload")
if not isinstance(payload_config, dict):
raise ReleaseToolError("release payload configuration must be an object")
@@ -341,18 +508,27 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
if not isinstance(tree, dict):
raise ReleaseToolError("payload python tree must be an object")
source_name = _required_string(tree, "source")
source_root = PROJECT_ROOT / source_name
if not source_root.is_dir() or source_root.is_symlink():
source_relative = _safe_relative_path(source_name)
destination_name = tree.get("destination", source_name)
if not isinstance(destination_name, str) or not destination_name:
raise ReleaseToolError("payload python tree destination must be a string")
destination_relative = _safe_relative_path(destination_name)
tree_root = _payload_source_path(
source_relative,
source_root,
packaged_relative=destination_relative,
)
if not tree_root.is_dir() or tree_root.is_symlink():
raise ReleaseToolError(f"payload source tree is invalid: {source_name}")
exclusions = set(_string_list(tree.get("exclude", []), "payload exclude"))
for source in sorted(source_root.rglob("*.py")):
relative_in_tree = source.relative_to(source_root).as_posix()
for source in sorted(tree_root.rglob("*.py")):
relative_in_tree = source.relative_to(tree_root).as_posix()
if relative_in_tree in exclusions:
continue
_validate_source_file(source)
result.append(
PayloadFile(
PurePosixPath(source_name) / PurePosixPath(relative_in_tree),
destination_relative / PurePosixPath(relative_in_tree),
source,
None,
0o644,
@@ -364,9 +540,27 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
for file_config in configured_files:
if not isinstance(file_config, dict):
raise ReleaseToolError("payload file entry must be an object")
path_text = _required_string(file_config, "path")
relative = _safe_relative_path(path_text)
source = PROJECT_ROOT.joinpath(*relative.parts)
legacy_path = file_config.get("path")
if legacy_path is not None:
if "source" in file_config or "destination" in file_config:
raise ReleaseToolError(
"payload file entry cannot combine path with source or destination"
)
path_text = _required_string(file_config, "path")
source_relative = _safe_relative_path(path_text)
destination_relative = source_relative
else:
source_relative = _safe_relative_path(
_required_string(file_config, "source")
)
destination_relative = _safe_relative_path(
_required_string(file_config, "destination")
)
source = _payload_source_path(
source_relative,
source_root,
packaged_relative=destination_relative,
)
_validate_source_file(source)
mode_text = _required_string(file_config, "mode")
try:
@@ -375,10 +569,29 @@ def collect_payload(config: Mapping[str, object]) -> list[PayloadFile]:
raise ReleaseToolError(f"invalid payload mode {mode_text!r}") from exc
if mode not in {0o644, 0o755}:
raise ReleaseToolError(f"unsupported payload mode: {mode_text}")
result.append(PayloadFile(relative, source, None, mode))
result.append(PayloadFile(destination_relative, source, None, mode))
return result
def _payload_source_path(
relative: PurePosixPath,
source_root: Path | None,
*,
packaged_relative: PurePosixPath | None = None,
) -> Path:
if source_root is None:
return PROJECT_ROOT.joinpath(*relative.parts)
candidates = [source_root.joinpath(*relative.parts)]
if relative.parts[:1] == ("src",):
candidates.append(source_root.joinpath(*relative.parts[1:]))
if packaged_relative is not None:
candidates.append(source_root.joinpath(*packaged_relative.parts))
for candidate in candidates:
if candidate.exists():
return candidate
return candidates[0]
def generate_manifest(
destination: Path,
*,
@@ -543,7 +756,7 @@ def verify_release(
if not (extracted / required).is_file():
raise ReleaseToolError(f"release archive is missing required file: {required}")
packaged_version = _version_from_source_file(
extracted / _required_string(config, "version_source")
extracted / _packaged_version_path(config)
)
if packaged_version != version:
raise ReleaseToolError(
@@ -568,16 +781,57 @@ def verify_release(
verify_signature(
manifest_path,
signature,
public_key or PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
public_key or _payload_source_for_destination(
config,
PurePosixPath("ptlinux/launcher-update-public.pem"),
),
)
elif require_signature:
raise ReleaseToolError("manifest signature is required but missing")
def _safe_signing_key_path(
private_key: Path,
*,
output_directory: Path | None = None,
) -> Path:
"""Validate that a private key is external, private, and a regular file."""
candidate = private_key.expanduser().absolute()
if candidate.is_symlink():
raise ReleaseToolError(
f"release signing key cannot be a symbolic link: {candidate}"
)
resolved = candidate.resolve()
location_pairs = (
(candidate, PROJECT_ROOT.absolute()),
(resolved, PROJECT_ROOT.resolve()),
(candidate, ARTIFACTS_ROOT.absolute()),
(resolved, ARTIFACTS_ROOT.resolve()),
)
if any(path == root or path.is_relative_to(root) for path, root in location_pairs):
raise ReleaseToolError(
"release signing key must be outside the repository and .artifacts: "
f"{candidate}"
)
if output_directory is not None:
output = output_directory.expanduser().resolve()
if resolved == output or resolved.is_relative_to(output):
raise ReleaseToolError(
f"release signing key must be outside release output: {candidate}"
)
if not resolved.is_file():
raise ReleaseToolError(f"release signing key is missing: {candidate}")
if resolved.stat().st_mode & 0o077:
raise ReleaseToolError(
"release signing key must not be accessible by group or other users: "
f"{candidate}"
)
return resolved
def sign_file(source: Path, signature: Path, private_key: Path) -> None:
key = private_key.expanduser().resolve()
if not key.is_file() or key.is_symlink():
raise ReleaseToolError(f"manifest signing key is missing: {key}")
key = _safe_signing_key_path(private_key)
openssl = shutil.which("openssl")
if openssl is None:
raise ReleaseToolError("OpenSSL is required to sign release manifests")
@@ -730,6 +984,65 @@ def _write_deterministic_archive(
temporary.unlink(missing_ok=True)
def _write_legacy_launcher_archive(
destination: Path,
*,
archive_root: str,
version: str,
mtime: int,
payload: Sequence[PayloadFile],
) -> None:
"""Write the original wrapped launcher archive byte-for-byte deterministically."""
metadata = (
json.dumps({"schema": 1, "version": version}, indent=2, sort_keys=True) + "\n"
).encode("utf-8")
with destination.open("xb") as raw:
with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=mtime) as compressed:
with tarfile.open(
fileobj=compressed,
mode="w",
format=tarfile.PAX_FORMAT,
) as bundle:
for directory in (
archive_root,
f"{archive_root}/ptdiag",
f"{archive_root}/ptlinux",
f"{archive_root}/ptlinux/native",
):
information = _tar_info(directory, mtime, 0o755)
information.type = b"5"
bundle.addfile(information)
with io.BytesIO(metadata) as handle:
_add_tar_file(
bundle,
information_name=f"{archive_root}/release.json",
handle=handle,
size=len(metadata),
mtime=mtime,
mode=0o644,
)
for item in sorted(
payload,
key=lambda entry: entry.relative_path.as_posix(),
):
if item.source is None:
raise ReleaseToolError(
f"legacy payload has no source: {item.relative_path}"
)
with item.source.open("rb") as handle:
_add_tar_file(
bundle,
information_name=(
f"{archive_root}/{item.relative_path.as_posix()}"
),
handle=handle,
size=item.source.stat().st_size,
mtime=mtime,
mode=item.mode,
)
def _tar_info(name: str, mtime: int, mode: int) -> tarfile.TarInfo:
information = tarfile.TarInfo(name)
information.uid = 0
@@ -1005,12 +1318,13 @@ def _safe_output_directory(path: Path) -> Path:
if expanded.is_symlink():
raise ReleaseToolError(f"output directory cannot be a symbolic link: {expanded}")
resolved = expanded.resolve()
project_dist_path = PROJECT_ROOT / "dist"
if project_dist_path.is_symlink():
project_release_path = RELEASE_ARTIFACTS_ROOT
if project_release_path.is_symlink():
raise ReleaseToolError(
f"repository dist directory cannot be a symbolic link: {project_dist_path}"
"repository release-artifact directory cannot be a symbolic link: "
f"{project_release_path}"
)
project_dist = project_dist_path.resolve()
project_releases = project_release_path.resolve()
temporary_roots = (
Path(tempfile.gettempdir()).resolve(),
Path("/var/tmp").resolve(),
@@ -1023,12 +1337,16 @@ def _safe_output_directory(path: Path) -> Path:
}
if resolved in unsafe or resolved in PROJECT_ROOT.resolve().parents:
raise ReleaseToolError(f"refusing unsafe output directory: {resolved}")
if resolved == project_dist or resolved.is_relative_to(project_dist):
if resolved == project_releases or resolved.is_relative_to(project_releases):
return resolved
if resolved.is_relative_to(PROJECT_ROOT.resolve()):
raise ReleaseToolError(
"release output inside the repository must be below .artifacts/releases"
)
if any(resolved.is_relative_to(root) for root in temporary_roots):
return resolved
raise ReleaseToolError(
"release output must be the repository dist directory or a temporary directory"
"release output must be below .artifacts/releases or a temporary directory"
)
@@ -1111,6 +1429,21 @@ def _validate_repository_url(value: str) -> None:
)
def _validate_legacy_version(value: str, label: str) -> None:
if _LEGACY_VERSION_PATTERN.fullmatch(value) is None:
raise ReleaseToolError(f"{label} must use X.Y.Z format: {value!r}")
def _payload_source_for_destination(
config: Mapping[str, object],
destination: PurePosixPath,
) -> Path:
for item in collect_payload(config):
if item.relative_path == destination and item.source is not None:
return item.source
raise ReleaseToolError(f"release payload is missing {destination.as_posix()}")
def _read_json_object(path: Path) -> dict[str, object]:
try:
value = json.loads(
@@ -1166,7 +1499,7 @@ def _set_staged_source_version(
config: Mapping[str, object],
version: str,
) -> None:
path = staging / _required_string(config, "version_source")
path = staging / _packaged_version_path(config)
try:
contents = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
@@ -1212,6 +1545,17 @@ def _write_atomic(path: Path, data: bytes) -> None:
temporary.unlink(missing_ok=True)
def _packaged_version_path(config: Mapping[str, object]) -> PurePosixPath:
value = config.get("packaged_version_path")
if value is None:
# Older repository fixtures used one path because source and archive
# layouts were identical.
value = _required_string(config, "version_source")
if not isinstance(value, str) or not value:
raise ReleaseToolError("packaged_version_path must be a non-empty string")
return _safe_relative_path(value)
def _git_output(*arguments: str) -> str:
try:
result = subprocess.run(

View File

@@ -0,0 +1,25 @@
"""Repository-only paths shared by maintainer scripts and tests."""
from __future__ import annotations
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
ARTIFACTS_ROOT = PROJECT_ROOT / ".artifacts"
RELEASE_ARTIFACTS_ROOT = ARTIFACTS_ROOT / "releases"
def ensure_project_imports() -> None:
"""Put checkout-only import roots ahead of ambient Python installations."""
ordered = [SOURCE_ROOT, PROJECT_ROOT]
for path in ordered:
text = str(path)
while text in sys.path:
sys.path.remove(text)
for path in reversed(ordered):
if path.is_dir():
sys.path.insert(0, str(path))

View File

@@ -9,10 +9,12 @@ from pathlib import Path
import re
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
if __package__:
from .repository_paths import PROJECT_ROOT, ensure_project_imports
else:
from repository_paths import PROJECT_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, load_config # noqa: E402
from ptlinux.update_resolver import SemanticVersion

View File

@@ -7,10 +7,12 @@ import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
if __package__:
from .repository_paths import SOURCE_ROOT, ensure_project_imports
else:
from repository_paths import SOURCE_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ( # noqa: E402
ReleaseToolError,
sign_file,
@@ -26,7 +28,7 @@ def main() -> int:
parser.add_argument(
"--public-key",
type=Path,
default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
default=SOURCE_ROOT / "ptlinux/launcher-update-public.pem",
)
arguments = parser.parse_args()
try:

View File

@@ -7,10 +7,12 @@ import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
if __package__:
from .repository_paths import SOURCE_ROOT, ensure_project_imports
else:
from repository_paths import SOURCE_ROOT, ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, verify_signature # noqa: E402
@@ -21,7 +23,7 @@ def main() -> int:
parser.add_argument(
"--public-key",
type=Path,
default=PROJECT_ROOT / "ptlinux/launcher-update-public.pem",
default=SOURCE_ROOT / "ptlinux/launcher-update-public.pem",
)
arguments = parser.parse_args()
try:

View File

@@ -7,10 +7,12 @@ import argparse
from pathlib import Path
import sys
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
if __package__:
from .repository_paths import ensure_project_imports
else:
from repository_paths import ensure_project_imports
ensure_project_imports()
from scripts.release_lib import ReleaseToolError, verify_release # noqa: E402

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"]
__version__ = "0.9.11"
__version__ = "1.0.8"

View File

@@ -15,6 +15,10 @@ from typing import Any
from uuid import uuid4
from ...command import CommandOutcome
from ...diagnostics.crash_dumps import (
inspect_minidump,
prepare_minidump_attachment,
)
from ...diagnostics.native_logs import LogFileState
from ...game.runtime import RuntimeErrorPT, runtime_settings_for_realm
from ...launcher import LauncherError
@@ -383,16 +387,14 @@ def play_locked(
runtime_issues["game_window_ready"] = game_window_ready
if game_readiness_signal is not None:
runtime_issues["game_readiness_signal"] = game_readiness_signal
if (
selection.kind == "proton7-container"
and runtime_issues["host_futex_fatal_count"]
):
runtime_issues["recommended_one_variable_comparison"] = "proton7"
elif selection.kind == "proton7" and runtime_issues["gstreamer_missing_libraries"]:
runtime_issues["recommended_one_variable_comparison"] = "proton7-container"
elif runtime_issues["dbghelp_symdeferred_assertion_count"]:
runtime_issues["recommended_one_variable_comparison"] = "umu"
recommended_comparison = recommended_runtime_comparison(
selection.kind,
runtime_issues,
)
if recommended_comparison is not None:
runtime_issues["recommended_one_variable_comparison"] = recommended_comparison
clean_exit = outcome.returncode == 0 and current_game_crash is None
session_issue = game_log_issue_reason(current_game_log)
launch_evidence = game_readiness_signal
if launch_evidence is None and current_game_log is not None:
launch_evidence = "game_log"
@@ -401,6 +403,8 @@ def play_locked(
launch_succeeded = launch_evidence is not None
report = {
"ok": clean_exit,
"degraded": session_issue is not None,
"session_issue": session_issue,
"launch_succeeded": launch_succeeded,
"dry_run": dry_run,
"runtime": asdict(selection),
@@ -429,6 +433,7 @@ def play_locked(
**window_launch_metadata,
"runtime_seconds": round(game_runtime_seconds),
"clean_exit": clean_exit,
"degraded": session_issue is not None,
"timed_out": outcome.timed_out,
"launch_succeeded": launch_succeeded,
"game_window_ready": game_window_ready,
@@ -439,6 +444,38 @@ def play_locked(
"realm": start_option.name,
**api._game_exit_metadata(outcome.returncode, selection.kind),
}
if not clean_exit or session_issue is not None:
common_game_metadata["failure_reason"] = game_failure_reason(
outcome,
current_game_log,
current_game_crash,
common_game_metadata,
)
failure_evidence: dict[str, object] = {
"native_game_log_present": current_game_log is not None,
}
if current_game_log is not None:
failure_evidence["native_game_log"] = game_log_telemetry_summary(
current_game_log
)
if current_game_crash is not None:
failure_evidence["crash_dump_info"] = crash_dump_telemetry_summary(
current_game_crash
)
crash_dump_attachment: dict[str, object] | None = None
if (
current_game_crash is not None
and settings.game_crash_dump_upload_enabled
and telemetry.enabled
):
crash_path = current_game_crash.get("path")
if isinstance(crash_path, str):
upload_status, crash_dump_attachment = prepare_minidump_attachment(
Path(crash_path)
)
else:
upload_status = "path_unavailable"
failure_evidence["crash_dump_upload_status"] = upload_status
if launch_succeeded:
if not launch_success_reported:
assert launch_evidence is not None
@@ -461,7 +498,7 @@ def play_locked(
"game_launch_failed",
severity="error",
message=(
"Game client wrote a crash dump"
game_crash_message(current_game_log)
if current_game_crash is not None
else "Game session timed out"
if outcome.timed_out
@@ -469,23 +506,32 @@ def play_locked(
),
metadata={
**common_game_metadata,
**failure_evidence,
"runtime_issues": runtime_issues,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
},
context={"operation": "launch_game", "stage": "game_start"},
attachment=crash_dump_attachment,
)
if launch_succeeded:
exit_message = None
if current_game_crash is not None:
exit_message = "Game client wrote a crash dump"
exit_message = game_crash_message(current_game_log)
elif outcome.timed_out:
exit_message = "Game session timed out"
elif session_issue == "direct3d_resource_exhaustion":
exit_message = (
"Game client exited after logging Direct3D resource exhaustion"
)
elif common_game_metadata.get("signal_inferred") is True:
signal_wrapper = (
"Proton" if selection.kind == "proton7" else "UMU"
)
exit_message = (
"Game session ended with status "
f"{common_game_metadata['raw_exit_code']}, consistent with "
f"{common_game_metadata['signal_name']} wrapped by UMU"
f"{common_game_metadata['signal_name']} wrapped by {signal_wrapper}"
)
elif "signal" in common_game_metadata:
exit_message = (
@@ -499,20 +545,24 @@ def play_locked(
severity=(
"error"
if current_game_crash is not None
else "info" if clean_exit else "warning"
else "warning"
if not clean_exit or session_issue is not None
else "info"
),
message=exit_message,
metadata=(
common_game_metadata
if clean_exit
if clean_exit and session_issue is None
else {
**common_game_metadata,
**failure_evidence,
"runtime_issues": runtime_issues,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
}
),
context={"operation": "launch_game", "stage": "exited"},
attachment=crash_dump_attachment,
)
if as_json or dry_run:
api._emit(report, as_json)
@@ -553,10 +603,15 @@ def game_exit_metadata(
)
return metadata
# UMU returns Popen's negative child status from a Python main function.
# SystemExit encodes -SIGKILL as 247 before our Python gamebox sees it.
# UMU and the retained Proton 7 Python entry point return a negative child
# status from subprocess. SystemExit encodes -SIGKILL as 247 before our
# Python gamebox sees it.
wrapped_sigkill = 256 - int(signal.SIGKILL)
if runtime_kind in {"umu", "proton7-container"} and returncode == wrapped_sigkill:
if (
runtime_kind in {"umu", "proton7", "proton7-container"}
and returncode == wrapped_sigkill
):
wrapper = "proton" if runtime_kind == "proton7" else "umu"
metadata.update(
{
"exit_code": 128 + int(signal.SIGKILL),
@@ -564,12 +619,75 @@ def game_exit_metadata(
"signal": int(signal.SIGKILL),
"signal_name": signal.SIGKILL.name,
"signal_inferred": True,
"signal_inference": "umu_python_system_exit",
"signal_inference": f"{wrapper}_python_system_exit",
}
)
return metadata
def recommended_runtime_comparison(
runtime_kind: str,
runtime_issues: dict[str, object],
) -> str | None:
"""Choose a useful one-variable comparison from launch-relevant evidence."""
if (
runtime_kind == "proton7-container"
and runtime_issues.get("host_futex_fatal_count")
):
return "proton7"
if (
runtime_kind == "proton7"
and runtime_issues.get("gstreamer_missing_libraries")
and (
runtime_issues.get("gstreamer_stream_error") is True
or runtime_issues.get("game_window_ready") is not True
)
):
return "proton7-container"
if runtime_issues.get("dbghelp_symdeferred_assertion_count"):
return "umu"
return None
def game_failure_reason(
outcome: CommandOutcome,
current_game_log: dict[str, object] | None,
current_game_crash: dict[str, object] | None,
exit_metadata: dict[str, object],
) -> str:
"""Classify the strongest evidence recorded for a failed or degraded session."""
if current_game_log and current_game_log.get("runtime_out_of_memory") is True:
return "direct3d_resource_exhaustion"
if current_game_crash is not None:
return "client_crash_dump"
if outcome.timed_out:
return "session_timeout"
if "signal" in exit_metadata:
return "process_signal"
return "nonzero_exit"
def game_log_issue_reason(
current_game_log: dict[str, object] | None,
) -> str | None:
"""Classify native-log evidence that degraded an otherwise clean process exit."""
if current_game_log and current_game_log.get("runtime_out_of_memory") is True:
return "direct3d_resource_exhaustion"
return None
def game_crash_message(current_game_log: dict[str, object] | None) -> str:
if (
current_game_log is not None
and current_game_log.get("runtime_out_of_memory") is True
):
return "Game client logged Direct3D resource exhaustion and wrote a crash dump"
return "Game client wrote a crash dump"
def signal_name(signal_number: int) -> str:
try:
return signal.Signals(signal_number).name
@@ -654,6 +772,7 @@ def current_game_log(
],
"failed_import_scenes": analysis.failed_import_scenes,
"stage_without_new_stage": analysis.stage_without_new_stage,
"tex_reclaim_count": analysis.tex_reclaim_count,
"module_code_sync_error": analysis.module_code_sync_error,
"module_code_sync_left": analysis.module_code_sync_left,
"module_code_sync_right": analysis.module_code_sync_right,
@@ -702,13 +821,84 @@ def current_game_crash(
)
if state is None:
return None
return {
report: dict[str, object] = {
"path": str(state.path),
"modified_at": datetime.fromtimestamp(
state.mtime_ns / 1_000_000_000
).astimezone().isoformat(),
"size": state.size,
}
minidump = inspect_minidump(state.path)
if minidump is not None:
report["minidump"] = minidump
return report
def game_log_telemetry_summary(report: dict[str, object]) -> dict[str, object]:
"""Keep the native-log evidence useful while bounding telemetry cardinality."""
keys = (
"modified_at",
"size",
"load_texture_to_material_failed",
"import_scene_threw",
"asset_pack_health_failed",
"managed_asset_integrity_failed",
"runtime_out_of_memory",
"out_of_memory_count",
"out_of_memory_effect_paths",
"failed_import_scenes",
"stage_without_new_stage",
"tex_reclaim_count",
"module_code_sync_error",
"module_code_sync_left",
"module_code_sync_right",
"module_code_sync_delta",
"banned_code_1",
"failed_file_find_count",
"failed_file_load_count",
"failed_file_read_count",
"failed_asset_file_count",
"failed_texture_file_count",
"failed_effect_file_count",
"missing_texture_reference_count",
"missing_effect_reference_count",
"missing_image_reference_count",
"missing_pack_image_reference_count",
)
summary = {key: report[key] for key in keys if key in report}
asset_references = report.get("asset_references")
if isinstance(asset_references, dict):
asset_summary = {
key: asset_references[key]
for key in (
"total_events",
"unique_valid_references",
"invalid_reference_events",
"catalog_available",
"managed_integrity_failed",
)
if key in asset_references
}
for source_key, output_key in (
("managed_present_paths", "managed_present_count"),
("managed_missing_paths", "managed_missing_count"),
("publisher_unmanaged_paths", "publisher_unmanaged_count"),
("unclassified_paths", "unclassified_count"),
):
paths = asset_references.get(source_key)
if isinstance(paths, (list, tuple)):
asset_summary[output_key] = len(paths)
if asset_summary:
summary["asset_reference_summary"] = asset_summary
return summary
def crash_dump_telemetry_summary(report: dict[str, object]) -> dict[str, object]:
"""Exclude the local dump path while retaining its bounded signature."""
keys = ("modified_at", "size", "minidump")
return {key: report[key] for key in keys if key in report}
def runtime_issue_summary(stderr: str) -> dict[str, object]:
@@ -826,6 +1016,16 @@ def print_game_outcome(
)
elif outcome.timed_out:
print("Game client timed out and was stopped.", file=sys.stderr)
elif (
outcome.returncode == 0
and game_log_issue_reason(current_game_log)
== "direct3d_resource_exhaustion"
):
print(
"Game client exited with status 0 after logging Direct3D resource "
"exhaustion.",
file=sys.stderr,
)
elif outcome.returncode == 0:
print("Game client exited normally.")
elif outcome.returncode is None:
@@ -894,8 +1094,8 @@ def print_interrupted_game_log(report: dict[str, object]) -> None:
print(
"Detected Direct3D resource exhaustion in the 32-bit game process"
+ effect_text
+ ". Restarting the client can recover temporarily; depot repair will not "
"fix this failure.",
+ ". Restarting clears the process address space, but this condition can "
"recur; depot repair will not fix it.",
file=sys.stderr,
)
elif report["managed_asset_integrity_failed"]:

View File

@@ -0,0 +1,358 @@
"""Bounded, dependency-free crash signatures for Windows minidumps."""
from __future__ import annotations
import base64
from dataclasses import dataclass
import gzip
import hashlib
from pathlib import Path, PureWindowsPath
import struct
_MINIDUMP_SIGNATURE = b"MDMP"
_HEADER_SIZE = 32
_DIRECTORY_ENTRY_SIZE = 12
_MODULE_ENTRY_SIZE = 108
_THREAD_ENTRY_SIZE = 48
_EXCEPTION_STREAM_TYPE = 6
_MODULE_LIST_STREAM_TYPE = 4
_SYSTEM_INFO_STREAM_TYPE = 7
_THREAD_LIST_STREAM_TYPE = 3
_MAX_DUMP_BYTES = 32 * 1024 * 1024
_MAX_STREAMS = 256
_MAX_MODULES = 4096
_MAX_THREADS = 4096
_MAX_STACK_BYTES = 64 * 1024
_MAX_STACK_SIGNATURES = 12
MAX_CRASH_DUMP_UPLOAD_BYTES = 2 * 1024 * 1024
MAX_CRASH_DUMP_COMPRESSED_BYTES = 30 * 1024
MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS = 40 * 1024
_ARCHITECTURE_NAMES = {
0: "x86",
5: "arm",
6: "ia64",
9: "x86_64",
12: "arm64",
}
_EXCEPTION_NAMES = {
0x80000003: "breakpoint",
0xC0000005: "access_violation",
0xC0000017: "no_memory",
0xC0000094: "integer_divide_by_zero",
0xC00000FD: "stack_overflow",
0xC0000409: "stack_buffer_overrun",
0xE06D7363: "msvc_cpp_exception",
}
@dataclass(frozen=True)
class _Module:
base: int
size: int
name: str
system: bool
def inspect_minidump(path: Path) -> dict[str, object] | None:
"""Return a privacy-safe crash signature, or ``None`` for an unusable dump.
The signature keeps stable exception and module offsets without absolute paths
or memory addresses. Full dump attachment is handled by the separate opt-in path.
"""
try:
with path.open("rb") as handle:
data = handle.read(_MAX_DUMP_BYTES + 1)
except OSError:
return None
if len(data) < _HEADER_SIZE or len(data) > _MAX_DUMP_BYTES:
return None
try:
return _inspect_minidump_bytes(data)
except (OverflowError, struct.error, UnicodeError, ValueError):
return None
def prepare_minidump_attachment(
path: Path,
) -> tuple[str, dict[str, object] | None]:
"""Compress one explicitly approved dump for bounded telemetry delivery."""
try:
with path.open("rb") as handle:
data = handle.read(MAX_CRASH_DUMP_UPLOAD_BYTES + 1)
except OSError:
return "unreadable", None
if len(data) > MAX_CRASH_DUMP_UPLOAD_BYTES:
return "source_too_large", None
try:
if _inspect_minidump_bytes(data) is None:
return "invalid_minidump", None
except (OverflowError, struct.error, UnicodeError, ValueError):
return "invalid_minidump", None
compressed = gzip.compress(data, compresslevel=9, mtime=0)
if len(compressed) > MAX_CRASH_DUMP_COMPRESSED_BYTES:
return "compressed_too_large", None
encoded = base64.urlsafe_b64encode(compressed).rstrip(b"=").decode("ascii")
if len(encoded) > MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS:
return "encoded_too_large", None
return (
"included",
{
"schema": 1,
"kind": "windows_minidump",
"encoding": "gzip+base64url",
"original_size": len(data),
"compressed_size": len(compressed),
"sha256": hashlib.sha256(data).hexdigest(),
"data_base64url": encoded,
},
)
def _inspect_minidump_bytes(data: bytes) -> dict[str, object] | None:
if len(data) < _HEADER_SIZE or data[:4] != _MINIDUMP_SIGNATURE:
return None
_stream_count, directory_rva = struct.unpack_from("<II", data, 8)
if _stream_count > _MAX_STREAMS:
return None
_require_range(data, directory_rva, _stream_count * _DIRECTORY_ENTRY_SIZE)
streams: dict[int, tuple[int, int]] = {}
for index in range(_stream_count):
offset = directory_rva + index * _DIRECTORY_ENTRY_SIZE
stream_type, size, rva = struct.unpack_from("<III", data, offset)
_require_range(data, rva, size)
streams.setdefault(stream_type, (rva, size))
exception_stream = streams.get(_EXCEPTION_STREAM_TYPE)
if exception_stream is None or exception_stream[1] < 40:
return None
exception_rva, _exception_size = exception_stream
thread_id = _read_u32(data, exception_rva)
exception_code = _read_u32(data, exception_rva + 8)
exception_flags = _read_u32(data, exception_rva + 12)
exception_address = _read_u64(data, exception_rva + 24)
exception_parameters = _read_exception_parameters(
data,
exception_rva,
_exception_size,
)
modules = _read_modules(data, streams.get(_MODULE_LIST_STREAM_TYPE))
exception_module = _module_for_address(modules, exception_address)
architecture = _read_architecture(data, streams.get(_SYSTEM_INFO_STREAM_TYPE))
result: dict[str, object] = {
"format": "minidump",
"architecture": architecture,
"exception_code": f"0x{exception_code:08x}",
"exception_kind": _EXCEPTION_NAMES.get(exception_code, "unknown"),
"exception_flags": f"0x{exception_flags:08x}",
"module_count": len(modules),
}
if exception_module is not None:
result.update(
{
"exception_module": exception_module.name,
"exception_module_offset": (
f"0x{exception_address - exception_module.base:x}"
),
}
)
stack_pointer = _read_exception_stack_pointer(
data,
exception_stream,
architecture,
)
stack = _read_exception_stack(
data,
streams.get(_THREAD_LIST_STREAM_TYPE),
thread_id,
stack_pointer=stack_pointer,
)
if stack is not None:
pointer_size = 8 if architecture in {"x86_64", "arm64", "ia64"} else 4
signatures = _application_stack_signatures(
stack,
pointer_size,
modules,
excluded_addresses=frozenset(exception_parameters),
)
if signatures:
result["application_stack"] = signatures
return result
def _read_modules(
data: bytes,
stream: tuple[int, int] | None,
) -> tuple[_Module, ...]:
if stream is None or stream[1] < 4:
return ()
rva, size = stream
count = _read_u32(data, rva)
if count > _MAX_MODULES or 4 + count * _MODULE_ENTRY_SIZE > size:
raise ValueError("invalid minidump module list")
modules: list[_Module] = []
for index in range(count):
offset = rva + 4 + index * _MODULE_ENTRY_SIZE
base = _read_u64(data, offset)
image_size = _read_u32(data, offset + 8)
name_rva = _read_u32(data, offset + 20)
module_path = _read_utf16_string(data, name_rva)
name = PureWindowsPath(module_path).name or "unknown"
folded_path = module_path.replace("/", "\\").casefold()
modules.append(
_Module(
base=base,
size=image_size,
name=name,
system="\\windows\\" in folded_path,
)
)
return tuple(modules)
def _read_architecture(
data: bytes,
stream: tuple[int, int] | None,
) -> str:
if stream is None or stream[1] < 2:
return "unknown"
architecture = struct.unpack_from("<H", data, stream[0])[0]
return _ARCHITECTURE_NAMES.get(architecture, f"architecture_{architecture}")
def _read_exception_stack(
data: bytes,
stream: tuple[int, int] | None,
thread_id: int,
*,
stack_pointer: int | None = None,
) -> bytes | None:
if stream is None or stream[1] < 4:
return None
rva, size = stream
count = _read_u32(data, rva)
if count > _MAX_THREADS or 4 + count * _THREAD_ENTRY_SIZE > size:
raise ValueError("invalid minidump thread list")
for index in range(count):
offset = rva + 4 + index * _THREAD_ENTRY_SIZE
if _read_u32(data, offset) != thread_id:
continue
stack_start = _read_u64(data, offset + 24)
reported_stack_size = _read_u32(data, offset + 32)
stack_rva = _read_u32(data, offset + 36)
stack_offset = 0
if (
stack_pointer is not None
and stack_start <= stack_pointer < stack_start + reported_stack_size
):
stack_offset = stack_pointer - stack_start
stack_size = min(
reported_stack_size - stack_offset,
_MAX_STACK_BYTES,
)
_require_range(data, stack_rva + stack_offset, stack_size)
return data[stack_rva + stack_offset : stack_rva + stack_offset + stack_size]
return None
def _read_exception_parameters(
data: bytes,
exception_rva: int,
exception_size: int,
) -> tuple[int, ...]:
parameter_count = _read_u32(data, exception_rva + 32)
if parameter_count > 15 or 40 + parameter_count * 8 > exception_size:
raise ValueError("invalid minidump exception parameters")
return tuple(
_read_u64(data, exception_rva + 40 + index * 8)
for index in range(parameter_count)
)
def _read_exception_stack_pointer(
data: bytes,
stream: tuple[int, int],
architecture: str,
) -> int | None:
rva, size = stream
if size < 168:
return None
context_size = _read_u32(data, rva + 160)
context_rva = _read_u32(data, rva + 164)
if context_size == 0 or context_rva == 0:
return None
_require_range(data, context_rva, context_size)
if architecture == "x86" and context_size >= 200:
return _read_u32(data, context_rva + 196)
if architecture == "x86_64" and context_size >= 160:
return _read_u64(data, context_rva + 152)
return None
def _application_stack_signatures(
stack: bytes,
pointer_size: int,
modules: tuple[_Module, ...],
*,
excluded_addresses: frozenset[int] = frozenset(),
) -> list[dict[str, str]]:
result: list[dict[str, str]] = []
seen: set[tuple[str, int]] = set()
format_code = "<Q" if pointer_size == 8 else "<I"
for offset in range(0, len(stack) - pointer_size + 1, pointer_size):
address = struct.unpack_from(format_code, stack, offset)[0]
if address in excluded_addresses:
continue
module = _module_for_address(modules, address)
if module is None or module.system:
continue
module_offset = address - module.base
key = (module.name.casefold(), module_offset)
if key in seen:
continue
seen.add(key)
result.append(
{
"module": module.name,
"offset": f"0x{module_offset:x}",
}
)
if len(result) >= _MAX_STACK_SIGNATURES:
break
return result
def _module_for_address(
modules: tuple[_Module, ...],
address: int,
) -> _Module | None:
for module in modules:
if module.base <= address < module.base + module.size:
return module
return None
def _read_utf16_string(data: bytes, rva: int) -> str:
byte_length = _read_u32(data, rva)
if byte_length > 64 * 1024 or byte_length % 2:
raise ValueError("invalid minidump string")
_require_range(data, rva + 4, byte_length)
return data[rva + 4 : rva + 4 + byte_length].decode("utf-16-le")
def _read_u32(data: bytes, offset: int) -> int:
_require_range(data, offset, 4)
return struct.unpack_from("<I", data, offset)[0]
def _read_u64(data: bytes, offset: int) -> int:
_require_range(data, offset, 8)
return struct.unpack_from("<Q", data, offset)[0]
def _require_range(data: bytes, offset: int, size: int) -> None:
if offset < 0 or size < 0 or offset > len(data) or size > len(data) - offset:
raise ValueError("minidump range is outside the file")

View File

@@ -53,6 +53,10 @@ WINETRICKS_CODEPAGE_PROBE = (
)
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
RUNTIME_DEFAULT_SYNC = "runtime-default"
PROTON_DEBUG_ENVIRONMENT_VARIABLE = "PTLINUX_PROTON_DEBUG"
PROTON_DEBUG_WINE_CHANNELS = (
"+timestamp,+pid,+tid,+seh,+unwind,+debugstr,+loaddll,+mscoree"
)
CACHYOS_MARKER = "cachyos"
CURRENT_UMU_DISTRIBUTIONS = frozenset(
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
@@ -472,6 +476,10 @@ def run_game(
*game_arguments,
]
environment = _umu_environment(paths, selection)
# Build 22.80.90 can consume an uninitialized heap value as a vector
# length under current Wine. Proton's scoped heap-zero mode prevents the
# resulting std::length_error without changing publisher files.
environment["PROTON_HEAP_ZERO_MEMORY"] = "1"
elif selection.kind == "proton7-container":
if not selection.proton_path:
raise RuntimeErrorPT("Proton script path is missing")
@@ -770,6 +778,7 @@ def _proton_environment(
) -> dict[str, str]:
if not selection.steam_root:
raise RuntimeErrorPT("Steam root is missing for Proton")
debug_logging = os.environ.get(PROTON_DEBUG_ENVIRONMENT_VARIABLE) == "1"
environment = {
"STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root,
"STEAM_COMPAT_DATA_PATH": str(paths.compat_data),
@@ -777,10 +786,12 @@ def _proton_environment(
"SteamAppId": "0",
"SteamGameId": "0",
"WINEPREFIX": str(paths.proton_prefix),
"WINEDEBUG": "-all",
"PROTON_LOG": "0",
"WINEDEBUG": PROTON_DEBUG_WINE_CHANNELS if debug_logging else "-all",
"PROTON_LOG": "1" if debug_logging else "0",
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
}
if debug_logging:
environment["PROTON_LOG_DIR"] = str(paths.logs)
environment.update(_proton_sync_environment(synchronization))
return environment

View File

@@ -39,6 +39,7 @@ class Settings:
realm: str = "Babel"
ui_scale: str = "auto"
telemetry_enabled: bool = True
game_crash_dump_upload_enabled: bool = False
borderless: bool = False
suppress_unresponsive_warnings: bool = True
runtime: str = "auto"
@@ -158,6 +159,11 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
realm=_string(value, "realm", "Babel").strip(),
ui_scale=_string(value, "ui_scale", "auto"),
telemetry_enabled=_boolean(value, "telemetry_enabled", True),
game_crash_dump_upload_enabled=_boolean(
value,
"game_crash_dump_upload_enabled",
False,
),
borderless=_migrated_borderless(value),
allow_window_resizing=_boolean(
value,

View File

@@ -19,6 +19,11 @@ from types import TracebackType
from typing import Any
from .. import __version__
from ..diagnostics.crash_dumps import (
MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS,
MAX_CRASH_DUMP_COMPRESSED_BYTES,
MAX_CRASH_DUMP_UPLOAD_BYTES,
)
from ..paths import AppPaths
@@ -41,6 +46,7 @@ MAX_VALUE_DEPTH = 8
QUEUE_CAPACITY = 128
DELIVERY_TIMEOUT_SECONDS = 3.0
SHUTDOWN_WAIT_SECONDS = 0.25
CRASH_DUMP_SHUTDOWN_WAIT_SECONDS = DELIVERY_TIMEOUT_SECONDS + 0.5
ERROR_SUPPRESSION_WINDOW_SECONDS = 60.0
MAX_ERROR_FINGERPRINTS = 128
@@ -63,6 +69,7 @@ _RESOURCE_USAGE_EVENTS = frozenset(
"update_success",
}
)
_CRASH_DUMP_ATTACHMENT_EVENTS = frozenset({"game_exited", "game_launch_failed"})
VALID_SEVERITIES = frozenset({"debug", "info", "warning", "error", "critical"})
VALID_EVENTS = frozenset(
{
@@ -254,6 +261,7 @@ class TelemetryService:
self._worker: threading.Thread | None = None
self._environment_worker: threading.Thread | None = None
self._state_lock = threading.RLock()
self._crash_dump_attachment_enqueued = False
self._repeated_errors: dict[str, _RepeatedErrorState] = {}
self._previous_sys_excepthook: Any = None
self._previous_threading_excepthook: Any = None
@@ -372,6 +380,7 @@ class TelemetryService:
message: str | None = None,
metadata: Mapping[str, object] | None = None,
context: Mapping[str, object] | None = None,
attachment: Mapping[str, object] | None = None,
) -> bool:
"""Sanitize and enqueue one event without blocking on network I/O."""
@@ -431,10 +440,29 @@ class TelemetryService:
sanitized_metadata,
MAX_METADATA_BYTES,
)
if attachment:
sanitized_attachment = (
self._sanitize_attachment(attachment)
if event in _CRASH_DUMP_ATTACHMENT_EVENTS
else None
)
if sanitized_attachment is not None:
payload["attachment"] = sanitized_attachment
if len(self._facade._json_bytes(payload)) > MAX_REQUEST_BYTES:
payload.pop("attachment", None)
self._set_attachment_status(
payload,
"payload_too_large",
)
else:
self._set_attachment_status(payload, "invalid_attachment")
body = self._facade._encode_payload(payload)
if body is None:
return False
self._queue.put_nowait(body)
if "attachment" in payload:
with self._state_lock:
self._crash_dump_attachment_enqueued = True
if repeated_error is not None:
evicted = self._remember_repeated_error(repeated_error)
if evicted is not None:
@@ -663,7 +691,15 @@ class TelemetryService:
if not self._active or self._closing:
return
self._closing = True
deadline = time.monotonic() + max(0.0, timeout_seconds)
crash_dump_attachment_enqueued = self._crash_dump_attachment_enqueued
effective_timeout = max(
0.0,
timeout_seconds,
CRASH_DUMP_SHUTDOWN_WAIT_SECONDS
if crash_dump_attachment_enqueued
else 0.0,
)
deadline = time.monotonic() + effective_timeout
environment_worker = self._environment_worker
if isinstance(environment_worker, threading.Thread) and (
environment_worker is not threading.current_thread()
@@ -931,6 +967,79 @@ class TelemetryService:
truncate=self._facade._truncate_characters,
)
def _sanitize_attachment(
self,
attachment: Mapping[str, object],
) -> dict[str, object] | None:
"""Accept only the bounded, path-free minidump envelope we generate."""
value = dict(attachment)
if set(value) != {
"schema",
"kind",
"encoding",
"original_size",
"compressed_size",
"sha256",
"data_base64url",
}:
return None
if (
value.get("schema") != 1
or isinstance(value.get("schema"), bool)
or value.get("kind") != "windows_minidump"
or value.get("encoding") != "gzip+base64url"
):
return None
original_size = value.get("original_size")
compressed_size = value.get("compressed_size")
if (
isinstance(original_size, bool)
or not isinstance(original_size, int)
or original_size < 32
or original_size > MAX_CRASH_DUMP_UPLOAD_BYTES
or isinstance(compressed_size, bool)
or not isinstance(compressed_size, int)
or compressed_size < 1
or compressed_size > MAX_CRASH_DUMP_COMPRESSED_BYTES
):
return None
sha256 = value.get("sha256")
data = value.get("data_base64url")
if (
not isinstance(sha256, str)
or re.fullmatch(r"[0-9a-f]{64}", sha256) is None
or not isinstance(data, str)
or len(data) != (compressed_size * 4 + 2) // 3
or len(data) > MAX_CRASH_DUMP_ATTACHMENT_CHARACTERS
or re.fullmatch(r"[A-Za-z0-9_-]+", data) is None
):
return None
metadata = {key: item for key, item in value.items() if key != "data_base64url"}
sanitized = self._sanitize_value(metadata, key="attachment", depth=0)
if not isinstance(sanitized, dict):
return None
sanitized["data_base64url"] = data
return sanitized
def _set_attachment_status(
self,
payload: dict[str, object],
status: str,
) -> None:
current_metadata = payload.get("metadata")
metadata: dict[str, object] = {"crash_dump_upload_status": status}
if isinstance(current_metadata, dict):
metadata.update(
(key, value)
for key, value in current_metadata.items()
if key != "crash_dump_upload_status"
)
payload["metadata"] = self._facade._bound_mapping(
metadata,
MAX_METADATA_BYTES,
)
def _sanitize_text(self, value: str) -> str:
return self._facade._privacy_sanitize_text(
value,

View File

@@ -185,6 +185,9 @@ def open_settings_dialog(
value=setting_label(current_settings.ui_scale, UI_SCALE_LABELS)
)
telemetry_value = tk.BooleanVar(value=current_settings.telemetry_enabled)
crash_dump_upload_value = tk.BooleanVar(
value=current_settings.game_crash_dump_upload_enabled
)
suppress_unresponsive_value = tk.BooleanVar(
value=current_settings.suppress_unresponsive_warnings
)
@@ -322,6 +325,7 @@ def open_settings_dialog(
allow_window_resizing=allow_window_resizing_value.get(),
ui_scale=setting_value(ui_scale_value.get(), UI_SCALE_LABELS),
telemetry_enabled=telemetry_value.get(),
game_crash_dump_upload_enabled=crash_dump_upload_value.get(),
suppress_unresponsive_warnings=suppress_unresponsive_value.get(),
runtime=setting_value(runtime_value.get(), RUNTIME_LABELS),
proton_sync=setting_value(
@@ -590,7 +594,7 @@ def open_settings_dialog(
ttk.Checkbutton(
diagnostics_card,
name="settings_telemetry_enabled",
text="Send anonymous diagnostic and usage information",
text="Send launcher diagnostic and usage information",
variable=telemetry_value,
style="Settings.TCheckbutton",
cursor="hand2",
@@ -600,14 +604,35 @@ def open_settings_dialog(
diagnostics_card,
text=(
"Helps improve PristonTale EU Linux compatibility by sending anonymous "
"launcher errors, Linux and display versions, graphics/runtime compatibility, "
"and installation or update reliability. Game accounts, personal files, "
"hostnames, and hardware serial numbers are never collected."
"launcher errors, Linux and display versions, graphics/runtime "
"compatibility, and installation or update reliability. Routine "
"telemetry does not collect game accounts, personal files, hostnames, "
"or hardware serial numbers."
),
style="SettingsHint.TLabel",
wraplength=720,
)
diagnostics_hint.grid(row=1, column=0, sticky="w", pady=(7, 0))
ttk.Checkbutton(
diagnostics_card,
name="settings_game_crash_dump_upload_enabled",
text="Include compressed game crash dumps in error reports",
variable=crash_dump_upload_value,
style="Settings.TCheckbutton",
cursor="hand2",
takefocus=True,
).grid(row=2, column=0, sticky="w", pady=(12, 0))
crash_dump_hint = ttk.Label(
diagnostics_card,
text=(
"Off by default and requires telemetry above. A crash dump can contain "
"fragments of account, chat, or other game-process memory. Uploads are "
"compressed, integrity checked, and limited to 2 MiB before compression."
),
style="SettingsHint.TLabel",
wraplength=720,
)
crash_dump_hint.grid(row=3, column=0, sticky="w", pady=(7, 0))
overrides_card = ttk.LabelFrame(
content,
@@ -705,6 +730,9 @@ def open_settings_dialog(
diagnostics_hint.configure(
wraplength=max(140, viewport_width - round(42 * launcher_ui_scale))
)
crash_dump_hint.configure(
wraplength=max(140, viewport_width - round(42 * launcher_ui_scale))
)
footer_note.configure(
wraplength=(
max(140, viewport_width - round(42 * launcher_ui_scale))

View File

@@ -2,16 +2,16 @@
"application": "PristonTale-EU",
"archive": {
"layout": "flat",
"name": "PristonTale-EU-1.0.2.tar.gz",
"sha256": "1ade5b0c864e5644023ad30cb9fd14f46c85c55d68ed81d38e344e19023342b0",
"size": 1539982,
"url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.2/PristonTale-EU-1.0.2.tar.gz?sha256=1ade5b0c864e5644023ad30cb9fd14f46c85c55d68ed81d38e344e19023342b0"
"name": "PristonTale-EU-1.0.8.tar.gz",
"sha256": "f9e48505b17475c4b5de6d4ebc6835f3dfcb816aeeac3ae0b4c42cce546177a4",
"size": 1544812,
"url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.8/PristonTale-EU-1.0.8.tar.gz?sha256=f9e48505b17475c4b5de6d4ebc6835f3dfcb816aeeac3ae0b4c42cce546177a4"
},
"archive_sha256": "1ade5b0c864e5644023ad30cb9fd14f46c85c55d68ed81d38e344e19023342b0",
"archive_size": 1539982,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.2/PristonTale-EU-1.0.2.tar.gz?sha256=1ade5b0c864e5644023ad30cb9fd14f46c85c55d68ed81d38e344e19023342b0",
"archive_sha256": "f9e48505b17475c4b5de6d4ebc6835f3dfcb816aeeac3ae0b4c42cce546177a4",
"archive_size": 1544812,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v1.0.8/PristonTale-EU-1.0.8.tar.gz?sha256=f9e48505b17475c4b5de6d4ebc6835f3dfcb816aeeac3ae0b4c42cce546177a4",
"channel": "stable",
"commit": "17e3f21e87ea14ff346a3150be066b6eab7f3978",
"commit": "6a508476b90955705cdec106ba2151bd53d1f824",
"compatibility": {
"config_schema": 1,
"manifest_schema": 1,
@@ -36,7 +36,7 @@
},
{
"path": "ptlinux/__init__.py",
"sha256": "76fee8162979631fb5a34824ea0ff965c6710bc8389d4b4b2b87ac16ed40c9fb",
"sha256": "f8dcaf56174e23070242e4df9a06d6eb0e7a9b8d221ffaa6465db96227bb2976",
"size": 151
},
{
@@ -71,8 +71,8 @@
},
{
"path": "ptlinux/application/commands/play.py",
"sha256": "18c5c3f67ce8f769402993af28158c518abad447bb6e20be553d551545faac74",
"size": 30733
"sha256": "d4160bad360c7ba4299930b1040565468458ec4f88320220da0e2d29cf64b233",
"size": 36758
},
{
"path": "ptlinux/application/commands/settings.py",
@@ -124,6 +124,11 @@
"sha256": "ba51d92dde5c2c1b544100e71d03a0f4381162ce30731c97e7b3d2d26910c4b6",
"size": 75
},
{
"path": "ptlinux/diagnostics/crash_dumps.py",
"sha256": "b26e3805a1ef7f1ba464dec9f7ec5c453ecc450ea650183001c7703e6773d24a",
"size": 10351
},
{
"path": "ptlinux/diagnostics/native_logs.py",
"sha256": "3c07a45e89f69b2607288d7fd98f20e0679ee35599de03a3d4e3838ef93e91ba",
@@ -201,8 +206,8 @@
},
{
"path": "ptlinux/game/runtime.py",
"sha256": "082613fa2af0bd7dad892f02c9b3a162fcc09725bc31f039db27d155dccd30a8",
"size": 31787
"sha256": "cdeba4551aab6c54f2704d2aabc964a61b54a2cf9006567cf94a0a6aaf281061",
"size": 32421
},
{
"path": "ptlinux/game/runtime_view.py",
@@ -431,8 +436,8 @@
},
{
"path": "ptlinux/settings.py",
"sha256": "ecfc88c666669607fcc21c269515fb90034748bcf3be81ccfea4463f2a3e9f28",
"size": 10212
"sha256": "af7b1de17c69d499a3a91e22f2f2e2e6035ea79131cfc04800656468b762c870",
"size": 10363
},
{
"path": "ptlinux/system_identity.py",
@@ -496,8 +501,8 @@
},
{
"path": "ptlinux/telemetry/service.py",
"sha256": "8a2eff8c3199ff96b89d9f839e9e5286c78bad2c7eab5ef3690e1cd0b548a072",
"size": 29855
"sha256": "32404f39a5a85aab87794c17baa193a17b204ffe168080af1e5a7e6b9601777e",
"size": 33173
},
{
"path": "ptlinux/telemetry/transport.py",
@@ -641,8 +646,8 @@
},
{
"path": "ptlinux/ui/dialogs/settings_dialog.py",
"sha256": "3a78c155a388f38c3d2e358a648057f6d76161fd7f6690d10e711d5d7ca96d6e",
"size": 23733
"sha256": "700e514977c584cdb8b499491700a0689e766b28fb3f3d1b0a5b7c719051ca0e",
"size": 24739
},
{
"path": "ptlinux/ui/viewmodels/__init__.py",
@@ -691,21 +696,21 @@
},
{
"path": "release.json",
"sha256": "3fa3f47ce6291b441be5aebc7025b7e33f738fe37747e95007a6dc9c64a2e40c",
"sha256": "37426179c48b341bb457d376adaa415af3b28a009df574b0c0bb014a4e21d5fe",
"size": 218
}
],
"minimum_updater_version": "0.9.9",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.2",
"published_at": "2026-09-12T09:32:46Z",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.8",
"published_at": "2026-09-18T19:36:12Z",
"release": {
"notes_markdown": "# PristonTale EU Launcher 1.0.2\n\n## Changes\n\n- Reworked some game window and gamescope methods for Cinnamon. (`17e3f21`)\n- Added more folders and restructured classes inside them. (`2009afe`)\n- Reworked the project structure. (`775c987`)\n- Add channel-aware release tooling and updater recovery (`0a89323`)\n- Publish signed launcher release history (`cc48bf4`)\n- Upload files to \"updates\" (`84f8de5`)\n- Delete updates/stable.json.sig (`1329485`)\n- Delete updates/stable.json (`8f1655b`)\n- Fix v0.9.11 archive download cache (`be71d31`)\n- Upload files to \"updates\" (`46e11ae`)\n- Delete updates/stable.json.sig (`eeb993e`)\n- Delete updates/stable.json (`a90bd19`)\n\n---\n\nVersion: `1.0.2` \nChannel: `stable` \nTag: `v1.0.2` \nSource commit: `17e3f21e87ea14ff346a3150be066b6eab7f3978`\n",
"release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.2",
"summary": "Reworked some game window and gamescope methods for Cinnamon. (`17e3f21`)",
"title": "PristonTale EU Launcher 1.0.2"
"notes_markdown": "# PristonTale EU Launcher 1.0.8\n\nPrevents the Build 22.80.90 startup crash under UMU-Proton 10 and packages the\ncurrent launcher, diagnostics, telemetry, and native compatibility updates.\n\n## Changes\n\n- Enable Proton's heap-zero compatibility mode for UMU game launches to prevent\n the reproduced `std::length_error(\"vector too long\")` startup failure.\n- Improve crash-dump stack analysis and privacy-safe asset-failure telemetry.\n- Improve Direct3D resource-exhaustion handling and current runtime packaging.\n\n---\n\nVersion: `1.0.8` \nChannel: `stable` \nTag: `v1.0.8` \nSource commit: `6a508476b90955705cdec106ba2151bd53d1f824`\n",
"release_notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v1.0.8",
"summary": "Prevents the Build 22.80.90 startup crash under UMU-Proton 10 and packages the",
"title": "PristonTale EU Launcher 1.0.8"
},
"schema": 1,
"status": "active",
"tag": "v1.0.2",
"version": "1.0.2"
"tag": "v1.0.8",
"version": "1.0.8"
}

Binary file not shown.