steamlayer-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- steamlayer_core/__init__.py +110 -0
- steamlayer_core/_version.py +6 -0
- steamlayer_core/adapters.py +78 -0
- steamlayer_core/api.py +508 -0
- steamlayer_core/constants.py +14 -0
- steamlayer_core/discovery/__init__.py +19 -0
- steamlayer_core/discovery/dlc.py +189 -0
- steamlayer_core/discovery/engine.py +391 -0
- steamlayer_core/discovery/local.py +51 -0
- steamlayer_core/discovery/matcher.py +143 -0
- steamlayer_core/discovery/query_strategy.py +99 -0
- steamlayer_core/discovery/repository.py +130 -0
- steamlayer_core/discovery/web.py +37 -0
- steamlayer_core/domain/__init__.py +0 -0
- steamlayer_core/domain/exceptions.py +148 -0
- steamlayer_core/domain/models.py +248 -0
- steamlayer_core/events.py +81 -0
- steamlayer_core/http_client.py +135 -0
- steamlayer_core/patching/__init__.py +66 -0
- steamlayer_core/patching/config.py +95 -0
- steamlayer_core/patching/engine.py +338 -0
- steamlayer_core/patching/models.py +87 -0
- steamlayer_core/patching/scanner.py +217 -0
- steamlayer_core/patching/vault.py +225 -0
- steamlayer_core/patching/vendors.py +145 -0
- steamlayer_core/protocols.py +196 -0
- steamlayer_core/utils.py +18 -0
- steamlayer_core-0.1.0.dist-info/METADATA +188 -0
- steamlayer_core-0.1.0.dist-info/RECORD +32 -0
- steamlayer_core-0.1.0.dist-info/WHEEL +5 -0
- steamlayer_core-0.1.0.dist-info/licenses/LICENSE +21 -0
- steamlayer_core-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
steamlayer-core
|
|
3
|
+
===============
|
|
4
|
+
A headless, I/O-free library for Steam game identification, DLC hydration,
|
|
5
|
+
and emulator-agnostic patching.
|
|
6
|
+
|
|
7
|
+
Quick start
|
|
8
|
+
-----------
|
|
9
|
+
::
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from steamlayer_core import SteamLayerClient, GoldbergLocalVendorProvider, GoldbergConfigWriter
|
|
13
|
+
|
|
14
|
+
with SteamLayerClient(
|
|
15
|
+
vendor=GoldbergLocalVendorProvider(Path("./vendors")),
|
|
16
|
+
config_writer=GoldbergConfigWriter(),
|
|
17
|
+
) as client:
|
|
18
|
+
game = client.resolve(Path("/games/Portal 2"))
|
|
19
|
+
result = client.patch(game, Path("/games/Portal 2"))
|
|
20
|
+
print(result.targets_patched)
|
|
21
|
+
|
|
22
|
+
Public surface
|
|
23
|
+
--------------
|
|
24
|
+
Only the names imported here are considered stable API. Everything else is
|
|
25
|
+
an implementation detail subject to change without notice.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from steamlayer_core.adapters import (
|
|
29
|
+
FixedConfirmationHandler,
|
|
30
|
+
FixedDisambiguationHandler,
|
|
31
|
+
)
|
|
32
|
+
from steamlayer_core.api import SteamLayerClient, fetch_dlcs, patch_game, resolve_game
|
|
33
|
+
from steamlayer_core.domain.events import AmbiguousMatchEvent, LowConfidenceEvent
|
|
34
|
+
from steamlayer_core.domain.exceptions import (
|
|
35
|
+
AmbiguousMatchError,
|
|
36
|
+
AppIDNotFoundError,
|
|
37
|
+
ConfigurationError,
|
|
38
|
+
DLCCacheError,
|
|
39
|
+
DLCHydrationError,
|
|
40
|
+
EmulatorBinaryError,
|
|
41
|
+
LowConfidenceError,
|
|
42
|
+
PatchError,
|
|
43
|
+
SteamLayerError,
|
|
44
|
+
VaultError,
|
|
45
|
+
)
|
|
46
|
+
from steamlayer_core.domain.models import (
|
|
47
|
+
DiscoveryResult,
|
|
48
|
+
DLCInfo,
|
|
49
|
+
ResolutionSource,
|
|
50
|
+
ResolvedGame,
|
|
51
|
+
SteamlayerOptions,
|
|
52
|
+
)
|
|
53
|
+
from steamlayer_core.patching.config import GoldbergConfigWriter
|
|
54
|
+
from steamlayer_core.patching.engine import PatchEngine
|
|
55
|
+
from steamlayer_core.patching.models import PatchResult, PatchTarget
|
|
56
|
+
from steamlayer_core.patching.vendors import GoldbergLocalVendorProvider, LocalVendorProvider
|
|
57
|
+
from steamlayer_core.protocols import (
|
|
58
|
+
ConfigWriter,
|
|
59
|
+
ConfirmationHandler,
|
|
60
|
+
DisambiguationHandler,
|
|
61
|
+
ProgressCallback,
|
|
62
|
+
VendorProvider,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
# Client
|
|
67
|
+
"SteamLayerClient",
|
|
68
|
+
# API functions
|
|
69
|
+
"resolve_game",
|
|
70
|
+
"patch_game",
|
|
71
|
+
"fetch_dlcs",
|
|
72
|
+
# Models
|
|
73
|
+
"SteamlayerOptions",
|
|
74
|
+
"ResolvedGame",
|
|
75
|
+
"DiscoveryResult",
|
|
76
|
+
"ResolutionSource",
|
|
77
|
+
"DLCInfo",
|
|
78
|
+
# Events
|
|
79
|
+
"AmbiguousMatchEvent",
|
|
80
|
+
"LowConfidenceEvent",
|
|
81
|
+
# Exceptions
|
|
82
|
+
"SteamLayerError",
|
|
83
|
+
"AppIDNotFoundError",
|
|
84
|
+
"AmbiguousMatchError",
|
|
85
|
+
"LowConfidenceError",
|
|
86
|
+
"PatchError",
|
|
87
|
+
"VaultError",
|
|
88
|
+
"EmulatorBinaryError",
|
|
89
|
+
"DLCHydrationError",
|
|
90
|
+
"DLCCacheError",
|
|
91
|
+
"ConfigurationError",
|
|
92
|
+
# Protocols
|
|
93
|
+
"ConfigWriter",
|
|
94
|
+
"ConfirmationHandler",
|
|
95
|
+
"DisambiguationHandler",
|
|
96
|
+
"ProgressCallback",
|
|
97
|
+
"VendorProvider",
|
|
98
|
+
# Adapters
|
|
99
|
+
"FixedDisambiguationHandler",
|
|
100
|
+
"FixedConfirmationHandler",
|
|
101
|
+
# Vendors
|
|
102
|
+
"LocalVendorProvider",
|
|
103
|
+
"GoldbergLocalVendorProvider",
|
|
104
|
+
# Patching
|
|
105
|
+
"PatchEngine",
|
|
106
|
+
"PatchResult",
|
|
107
|
+
"PatchTarget",
|
|
108
|
+
# Config writers
|
|
109
|
+
"GoldbergConfigWriter",
|
|
110
|
+
]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from steamlayer_core.domain.exceptions import AppIDNotFoundError, AppIDResolutionError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from steamlayer_core.domain.events import AmbiguousMatchEvent, LowConfidenceEvent
|
|
10
|
+
from steamlayer_core.domain.models import DiscoveryResult
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FixedDisambiguationHandler:
|
|
16
|
+
"""
|
|
17
|
+
Adapter used when the user has already provided a specific AppID (e.g., via CLI).
|
|
18
|
+
Instead of asking, it searches the candidates for a match and returns it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, target_appid: int) -> None:
|
|
22
|
+
self.target_appid = target_appid
|
|
23
|
+
|
|
24
|
+
def handle_disambiguation(self, event: AmbiguousMatchEvent) -> DiscoveryResult:
|
|
25
|
+
logger.debug(f"Fixed handler looking for AppID {self.target_appid} in candidates.")
|
|
26
|
+
|
|
27
|
+
for candidate in event.candidates:
|
|
28
|
+
if candidate.appid == self.target_appid:
|
|
29
|
+
return candidate
|
|
30
|
+
|
|
31
|
+
raise ValueError(f"Provided AppID {self.target_appid} not found in resolution candidates.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FixedConfirmationHandler:
|
|
35
|
+
"""
|
|
36
|
+
Adapter used for 'non-interactive' mode (e.g., a --yes flag).
|
|
37
|
+
Automatically accepts or rejects low-confidence matches.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, auto_confirm: bool = True) -> None:
|
|
41
|
+
self.auto_confirm = auto_confirm
|
|
42
|
+
|
|
43
|
+
def handle_confirmation(self, event: LowConfidenceEvent) -> DiscoveryResult:
|
|
44
|
+
logger.info(
|
|
45
|
+
f"Auto-confirming ({self.auto_confirm}) match for {event.game_folder_name} "
|
|
46
|
+
f"with confidence {event.candidate.confidence:.2f}"
|
|
47
|
+
)
|
|
48
|
+
if self.auto_confirm:
|
|
49
|
+
return event.candidate
|
|
50
|
+
raise AppIDNotFoundError(event.game_folder_name)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CLIHandler:
|
|
54
|
+
"""
|
|
55
|
+
The primary interactive adapter for terminal users.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def handle_disambiguation(self, event: AmbiguousMatchEvent) -> DiscoveryResult:
|
|
59
|
+
print(f"\n[!] Multiple matches found for folder: '{event.game_folder_name}'")
|
|
60
|
+
for i, c in enumerate(event.candidates, 1):
|
|
61
|
+
print(f" {i}) {c.game_name} (AppID: {c.appid}) [Confidence: {c.confidence:.2f}]")
|
|
62
|
+
|
|
63
|
+
while True:
|
|
64
|
+
choice = input("\nSelect the correct game (or 'q' to abort): ").strip().lower()
|
|
65
|
+
if choice == "q":
|
|
66
|
+
raise KeyboardInterrupt()
|
|
67
|
+
if choice.isdigit() and 1 <= int(choice) <= len(event.candidates):
|
|
68
|
+
return event.candidates[int(choice) - 1]
|
|
69
|
+
|
|
70
|
+
def handle_confirmation(self, event: LowConfidenceEvent) -> DiscoveryResult:
|
|
71
|
+
print(f"\n[?] Low confidence match for '{event.game_folder_name}':")
|
|
72
|
+
print(f" Suggested: {event.candidate.game_name} (AppID: {event.candidate.appid})")
|
|
73
|
+
print(f" Confidence: {event.candidate.confidence:.2f} (Threshold: {event.threshold:.2f})")
|
|
74
|
+
|
|
75
|
+
choice = input("Is this correct? [y/N]: ").strip().lower()
|
|
76
|
+
if choice == "y":
|
|
77
|
+
return event.candidate
|
|
78
|
+
raise AppIDResolutionError("User rejected candidate.")
|
steamlayer_core/api.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""
|
|
2
|
+
steamlayer_core.api
|
|
3
|
+
===================
|
|
4
|
+
The sole public interface for ``steamlayer-core``.
|
|
5
|
+
|
|
6
|
+
Consumers should **only** import from this module.
|
|
7
|
+
Everything below the API boundary is an implementation detail.
|
|
8
|
+
|
|
9
|
+
Usage
|
|
10
|
+
-----
|
|
11
|
+
Quick one-shot functions for simple scripts::
|
|
12
|
+
|
|
13
|
+
game = resolve_game(Path("C:/games/Euro Truck Simulator 2"))
|
|
14
|
+
patch_game(game, Path("C:/games/Euro Truck Simulator 2"), vendor=vendor, config_writer=writer)
|
|
15
|
+
|
|
16
|
+
Stateful client for when you need shared lifecycle management (HTTP session,
|
|
17
|
+
resolver reuse, progress hooks wired once)::
|
|
18
|
+
|
|
19
|
+
with SteamLayerClient(vendor=vendor, config_writer=writer) as client:
|
|
20
|
+
game = client.resolve(Path("C:/games/Euro Truck Simulator 2"))
|
|
21
|
+
result = client.patch(game, Path("C:/games/Euro Truck Simulator 2"))
|
|
22
|
+
|
|
23
|
+
Classes
|
|
24
|
+
-------
|
|
25
|
+
SteamLayerClient
|
|
26
|
+
Stateful orchestrator. Manages HTTP session lifecycle, resolver
|
|
27
|
+
construction, and exposes ``resolve()``, ``patch()``, ``unpatch()``,
|
|
28
|
+
``fetch_dlcs()``, and ``is_patched()`` as methods.
|
|
29
|
+
|
|
30
|
+
Functions
|
|
31
|
+
---------
|
|
32
|
+
resolve_game(game_path, options, ...)
|
|
33
|
+
One-shot wrapper around ``SteamLayerClient.resolve()``. Runs the full
|
|
34
|
+
waterfall (local file → index → web search → disambiguation) and
|
|
35
|
+
optionally hydrates DLC metadata. Returns a ``ResolvedGame``.
|
|
36
|
+
|
|
37
|
+
patch_game(game, game_path, ...)
|
|
38
|
+
One-shot wrapper around ``SteamLayerClient.patch()``. Applies an
|
|
39
|
+
emulator patch to a game directory. Returns a ``PatchResult``.
|
|
40
|
+
|
|
41
|
+
fetch_dlcs(appid, ...)
|
|
42
|
+
One-shot wrapper around ``SteamLayerClient.fetch_dlcs()``. Hydrates
|
|
43
|
+
DLC metadata for a known AppID. Returns ``dict[int, DLCInfo]``.
|
|
44
|
+
|
|
45
|
+
Design notes
|
|
46
|
+
------------
|
|
47
|
+
- No ``input()`` or ``print()`` calls here or anywhere reachable from here.
|
|
48
|
+
- All human-interaction events are delegated to injected handlers.
|
|
49
|
+
- Exceptions are always typed (see ``steamlayer_core.domain.exceptions``).
|
|
50
|
+
- ``pathlib.Path | str`` is accepted for all filesystem arguments.
|
|
51
|
+
- ``SteamLayerClient`` is the canonical entry point; the module-level
|
|
52
|
+
functions are thin convenience wrappers that open and close a client
|
|
53
|
+
for a single call.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import logging
|
|
59
|
+
import pathlib
|
|
60
|
+
from typing import Any
|
|
61
|
+
|
|
62
|
+
from steamlayer_core.discovery.engine import ResolutionEngine
|
|
63
|
+
from steamlayer_core.domain.exceptions import AppIDNotFoundError, PatchError
|
|
64
|
+
from steamlayer_core.domain.models import (
|
|
65
|
+
DLCInfo,
|
|
66
|
+
ResolvedGame,
|
|
67
|
+
SteamlayerOptions,
|
|
68
|
+
)
|
|
69
|
+
from steamlayer_core.http_client import HTTPClient
|
|
70
|
+
from steamlayer_core.patching.models import PatchResult
|
|
71
|
+
from steamlayer_core.protocols import (
|
|
72
|
+
NULL_PROGRESS,
|
|
73
|
+
ConfigWriter,
|
|
74
|
+
ConfirmationHandler,
|
|
75
|
+
DisambiguationHandler,
|
|
76
|
+
HTTPClientProtocol,
|
|
77
|
+
ProgressCallback,
|
|
78
|
+
VendorProvider,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
log = logging.getLogger("steamlayer_core.api")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SteamLayerClient:
|
|
85
|
+
"""
|
|
86
|
+
A stateful, emulator-agnostic orchestrator for the SteamLayer pipeline.
|
|
87
|
+
|
|
88
|
+
The client serves as a universal interface for identifying and patching
|
|
89
|
+
Steam games. By decoupling the patching logic from specific emulator
|
|
90
|
+
implementations, it can support any Steam-emulator environment (Goldberg,
|
|
91
|
+
etc.) simply by injecting the appropriate VendorProvider and ConfigWriter.
|
|
92
|
+
|
|
93
|
+
The client manages the shared lifecycle of dependencies, such as HTTP
|
|
94
|
+
sessions and resolution engines, ensuring resource efficiency and
|
|
95
|
+
clean cleanup.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
options: `SteamlayerOptions | None`
|
|
100
|
+
Configuration for game and DLC resolution logic.
|
|
101
|
+
allow_network: `bool`
|
|
102
|
+
If True (default), the client ensures an HTTP session is available for
|
|
103
|
+
web-based metadata hydration and store searches.
|
|
104
|
+
vendor: `VendorProvider | None`
|
|
105
|
+
Provider for emulator binaries and metadata. Required for patching.
|
|
106
|
+
config_writer: `ConfigWriter | None`
|
|
107
|
+
Custom logic for writing emulator configuration files. If None,
|
|
108
|
+
the engine uses its default implementation.
|
|
109
|
+
on_disambiguation: `DisambiguationHandler | None`
|
|
110
|
+
Callback for resolving ties between multiple game candidates.
|
|
111
|
+
on_confirmation: `ConfirmationHandler | None`
|
|
112
|
+
Callback for verifying low-confidence matches.
|
|
113
|
+
http_client: `HTTPClientProtocol | None`
|
|
114
|
+
A custom HTTP client implementation. If provided, this instance
|
|
115
|
+
is used regardless of the ``allow_network`` setting. If None and
|
|
116
|
+
``allow_network`` is True, a default internal client is initialized.
|
|
117
|
+
progress: `ProgressCallback`
|
|
118
|
+
Hook for reporting operation status (e.g., UI progress bars).
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
options: SteamlayerOptions | None = None,
|
|
125
|
+
allow_network: bool = True,
|
|
126
|
+
vendor: VendorProvider | None = None,
|
|
127
|
+
config_writer: ConfigWriter | None = None,
|
|
128
|
+
on_disambiguation: DisambiguationHandler | None = None,
|
|
129
|
+
on_confirmation: ConfirmationHandler | None = None,
|
|
130
|
+
progress: ProgressCallback = NULL_PROGRESS,
|
|
131
|
+
http_client: HTTPClientProtocol | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.options = options or SteamlayerOptions()
|
|
134
|
+
self.allow_network = allow_network
|
|
135
|
+
self.vendor = vendor
|
|
136
|
+
self.config_writer = config_writer
|
|
137
|
+
self.on_disambiguation = on_disambiguation
|
|
138
|
+
self.on_confirmation = on_confirmation
|
|
139
|
+
self.progress = progress
|
|
140
|
+
self._http_client = http_client
|
|
141
|
+
self.__resolver: ResolutionEngine | None = None
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def _resolver(self) -> ResolutionEngine:
|
|
145
|
+
if self.__resolver is None:
|
|
146
|
+
self.__resolver = self._build_resolver()
|
|
147
|
+
return self.__resolver
|
|
148
|
+
|
|
149
|
+
def _build_resolver(self) -> ResolutionEngine:
|
|
150
|
+
from steamlayer_core.discovery.local import LocalDiscovery
|
|
151
|
+
from steamlayer_core.discovery.matcher import NameMatcher
|
|
152
|
+
from steamlayer_core.discovery.query_strategy import QueryStrategy
|
|
153
|
+
from steamlayer_core.discovery.repository import AppIndexRepository
|
|
154
|
+
from steamlayer_core.discovery.web import SteamWebClient
|
|
155
|
+
|
|
156
|
+
matcher = NameMatcher()
|
|
157
|
+
repo = AppIndexRepository(http=self._http_client)
|
|
158
|
+
web = SteamWebClient(http=self._http_client) if self._http_client else _OfflineSteamWebClient()
|
|
159
|
+
|
|
160
|
+
return ResolutionEngine(
|
|
161
|
+
local_discovery=LocalDiscovery(),
|
|
162
|
+
app_index_repository=repo,
|
|
163
|
+
steam_web_client=web,
|
|
164
|
+
name_matcher=matcher,
|
|
165
|
+
query_strategy=QueryStrategy(matcher),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def resolve(self, game_path: pathlib.Path | str) -> ResolvedGame:
|
|
169
|
+
"""
|
|
170
|
+
Identify a game and hydrate its metadata.
|
|
171
|
+
|
|
172
|
+
Uses the internal HTTP client if ``allow_network`` was set to True during
|
|
173
|
+
initialization.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
game_path: `pathlib.Path | str`
|
|
178
|
+
The filesystem path to the game's root directory.
|
|
179
|
+
|
|
180
|
+
Returns
|
|
181
|
+
-------
|
|
182
|
+
`ResolvedGame`
|
|
183
|
+
The identified game metadata.
|
|
184
|
+
"""
|
|
185
|
+
game_path = pathlib.Path(game_path)
|
|
186
|
+
|
|
187
|
+
result = self._resolver.resolve(
|
|
188
|
+
game_path,
|
|
189
|
+
options=self.options,
|
|
190
|
+
allow_network=self.allow_network,
|
|
191
|
+
on_disambiguation=self.on_disambiguation,
|
|
192
|
+
on_confirmation=self.on_confirmation,
|
|
193
|
+
progress=self.progress,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if result.appid is None:
|
|
197
|
+
raise AppIDNotFoundError(str(game_path))
|
|
198
|
+
|
|
199
|
+
dlcs: dict[int, DLCInfo] = {}
|
|
200
|
+
if self.options.fetch_dlcs and self.allow_network:
|
|
201
|
+
dlcs = self.fetch_dlcs(result.appid)
|
|
202
|
+
|
|
203
|
+
return ResolvedGame(
|
|
204
|
+
appid=result.appid,
|
|
205
|
+
game_name=result.game_name,
|
|
206
|
+
source=result.source,
|
|
207
|
+
confidence=result.confidence,
|
|
208
|
+
dlcs=dlcs,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def patch(self, game: ResolvedGame, game_path: pathlib.Path | str) -> PatchResult:
|
|
212
|
+
"""
|
|
213
|
+
Apply an emulator patch to the specified game directory.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
game: `ResolvedGame`
|
|
218
|
+
Metadata identifying the game and its components.
|
|
219
|
+
game_path: `pathlib.Path | str`
|
|
220
|
+
The filesystem path to the game's root directory.
|
|
221
|
+
|
|
222
|
+
Returns
|
|
223
|
+
-------
|
|
224
|
+
PatchResult
|
|
225
|
+
Summary of the files modified and backups created.
|
|
226
|
+
"""
|
|
227
|
+
from steamlayer_core.patching import PatchEngine
|
|
228
|
+
|
|
229
|
+
if self.vendor is None:
|
|
230
|
+
raise PatchError(
|
|
231
|
+
f"Failed to patch game at '{game_path}': No VendorProvider configured. "
|
|
232
|
+
"The SteamLayerClient requires a vendor (e.g., LocalVendorProvider) "
|
|
233
|
+
"to supply emulator binaries before a patch can be applied."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if self.config_writer is None:
|
|
237
|
+
raise PatchError(
|
|
238
|
+
f"Failed to patch game at '{game_path}': No ConfigWriter configured. "
|
|
239
|
+
"While a vendor provides the binaries, a ConfigWriter is required to "
|
|
240
|
+
"define how and where the emulator settings (AppID, DLCs) are stored."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
path = pathlib.Path(game_path)
|
|
244
|
+
engine = PatchEngine(vendor=self.vendor, config_writer=self.config_writer)
|
|
245
|
+
|
|
246
|
+
return engine.patch(game, path, progress=self.progress)
|
|
247
|
+
|
|
248
|
+
def unpatch(self, game_path: pathlib.Path | str, *, purge_vault: bool = True) -> list[pathlib.Path]:
|
|
249
|
+
"""
|
|
250
|
+
Revert a patch and restore original Steam binaries from the vault.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
game_path: `pathlib.Path | str`
|
|
255
|
+
The filesystem path to the game's root directory.
|
|
256
|
+
purge_vault: `bool`
|
|
257
|
+
Whether to delete the backup vault after a successful restoration.
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
`list[pathlib.Path]`
|
|
262
|
+
A list of files restored to their original locations.
|
|
263
|
+
"""
|
|
264
|
+
from steamlayer_core.patching.engine import PatchEngine
|
|
265
|
+
|
|
266
|
+
# Vendor is None because unpatching is emulator-agnostic; it only
|
|
267
|
+
# cares about the files stored in the vault.
|
|
268
|
+
engine = PatchEngine(vendor=None) # type: ignore
|
|
269
|
+
return engine.unpatch(pathlib.Path(game_path), purge_vault=purge_vault, progress=self.progress)
|
|
270
|
+
|
|
271
|
+
def fetch_dlcs(self, appid: int) -> dict[int, DLCInfo]:
|
|
272
|
+
"""
|
|
273
|
+
Hydrate DLC metadata for a known AppID.
|
|
274
|
+
|
|
275
|
+
Results are cached on disk as ``dlcs_{appid}.json`` under *cache_dir*.
|
|
276
|
+
On a cache hit the network is never touched. On a miss, the Steam Web
|
|
277
|
+
API is queried and the result is written back to disk before returning.
|
|
278
|
+
|
|
279
|
+
Parameters
|
|
280
|
+
----------
|
|
281
|
+
appid:
|
|
282
|
+
Steam AppID to look up.
|
|
283
|
+
|
|
284
|
+
Returns
|
|
285
|
+
-------
|
|
286
|
+
dict[int, DLCInfo]
|
|
287
|
+
Mapping of DLC AppID → ``DLCInfo``. ``DLCInfo.from_cache`` is
|
|
288
|
+
``True`` when the result was served from disk. Returns an empty
|
|
289
|
+
dict when no DLCs are found, the cache is cold, or the API is
|
|
290
|
+
unreachable.
|
|
291
|
+
"""
|
|
292
|
+
from steamlayer_core.discovery.dlc import DLCService
|
|
293
|
+
from steamlayer_core.discovery.repository import AppIndexRepository
|
|
294
|
+
from steamlayer_core.discovery.web import SteamWebClient
|
|
295
|
+
|
|
296
|
+
cache_dir = self.options.cache_dir
|
|
297
|
+
ttl = self.options.dlc_cache_ttl_seconds
|
|
298
|
+
cache_path = pathlib.Path(cache_dir) / f"dlcs_{appid}.json"
|
|
299
|
+
|
|
300
|
+
self.progress("fetching_dlcs", f"Hydrating DLC metadata for AppID {appid}...")
|
|
301
|
+
|
|
302
|
+
repo = AppIndexRepository(http=self._http_client)
|
|
303
|
+
web = SteamWebClient(http=self._http_client) if self._http_client else _OfflineSteamWebClient()
|
|
304
|
+
service = DLCService(
|
|
305
|
+
repo=repo,
|
|
306
|
+
web=web,
|
|
307
|
+
cache_path=cache_path,
|
|
308
|
+
allow_network=self.allow_network and self._http_client is not None,
|
|
309
|
+
ttl_seconds=ttl,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
raw, from_cache = service.fetch(appid)
|
|
313
|
+
return {int(k): DLCInfo(appid=int(k), name=str(v), from_cache=from_cache) for k, v in raw.items()}
|
|
314
|
+
|
|
315
|
+
def is_patched(self, game_path: pathlib.Path | str) -> bool:
|
|
316
|
+
"""
|
|
317
|
+
Determine if the specified directory contains an active SteamLayer patch.
|
|
318
|
+
|
|
319
|
+
Parameters
|
|
320
|
+
----------
|
|
321
|
+
game_path: `pathlib.Path | str`
|
|
322
|
+
The filesystem path to the game's root directory.
|
|
323
|
+
|
|
324
|
+
Returns
|
|
325
|
+
-------
|
|
326
|
+
bool
|
|
327
|
+
True if a valid vault and patch signature are detected.
|
|
328
|
+
"""
|
|
329
|
+
from steamlayer_core.patching.engine import PatchEngine
|
|
330
|
+
|
|
331
|
+
engine = PatchEngine(vendor=None) # type: ignore
|
|
332
|
+
return engine.is_patched(pathlib.Path(game_path))
|
|
333
|
+
|
|
334
|
+
def __enter__(self) -> SteamLayerClient:
|
|
335
|
+
if self._http_client is None and self.allow_network:
|
|
336
|
+
self._http_client = HTTPClient()
|
|
337
|
+
return self
|
|
338
|
+
|
|
339
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
340
|
+
if self._http_client is not None:
|
|
341
|
+
self._http_client.close()
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def resolve_game(
|
|
346
|
+
game_path: pathlib.Path | str,
|
|
347
|
+
options: SteamlayerOptions | None = None,
|
|
348
|
+
*,
|
|
349
|
+
allow_network: bool = True,
|
|
350
|
+
on_disambiguation: DisambiguationHandler | None = None,
|
|
351
|
+
on_confirmation: ConfirmationHandler | None = None,
|
|
352
|
+
progress: ProgressCallback = NULL_PROGRESS,
|
|
353
|
+
) -> ResolvedGame:
|
|
354
|
+
"""
|
|
355
|
+
Resolve a game directory to a Steam AppID and (optionally) its DLC list.
|
|
356
|
+
|
|
357
|
+
Parameters
|
|
358
|
+
----------
|
|
359
|
+
game_path:
|
|
360
|
+
Path to the game's root directory.
|
|
361
|
+
options:
|
|
362
|
+
Resolution configuration. Defaults to strict mode with network enabled.
|
|
363
|
+
allow_network: bool
|
|
364
|
+
Whether to allow network access for metadata hydration.
|
|
365
|
+
on_disambiguation:
|
|
366
|
+
Handler for ambiguous matches. If ``None``, raises
|
|
367
|
+
``AmbiguousMatchError`` when disambiguation is needed.
|
|
368
|
+
on_confirmation:
|
|
369
|
+
Handler for low-confidence matches. If ``None``, raises
|
|
370
|
+
``LowConfidenceError`` when confirmation is needed.
|
|
371
|
+
progress:
|
|
372
|
+
Optional progress hook for UI updates.
|
|
373
|
+
|
|
374
|
+
Returns
|
|
375
|
+
-------
|
|
376
|
+
ResolvedGame
|
|
377
|
+
Contains ``appid``, ``game_name``, ``source``, ``confidence``, and
|
|
378
|
+
``dlcs`` (populated if ``options.fetch_dlcs`` is True).
|
|
379
|
+
|
|
380
|
+
Raises
|
|
381
|
+
------
|
|
382
|
+
AppIDNotFoundError
|
|
383
|
+
No candidate could be found.
|
|
384
|
+
AmbiguousMatchError
|
|
385
|
+
Tie-breaking needed but no ``on_disambiguation`` handler provided.
|
|
386
|
+
LowConfidenceError
|
|
387
|
+
Confirmation needed but no ``on_confirmation`` handler provided.
|
|
388
|
+
"""
|
|
389
|
+
with SteamLayerClient(
|
|
390
|
+
options=options,
|
|
391
|
+
allow_network=allow_network,
|
|
392
|
+
on_disambiguation=on_disambiguation,
|
|
393
|
+
on_confirmation=on_confirmation,
|
|
394
|
+
progress=progress,
|
|
395
|
+
) as client:
|
|
396
|
+
return client.resolve(game_path)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def patch_game(
|
|
400
|
+
game: ResolvedGame,
|
|
401
|
+
game_path: pathlib.Path | str,
|
|
402
|
+
*,
|
|
403
|
+
vendor: VendorProvider,
|
|
404
|
+
config_writer: ConfigWriter,
|
|
405
|
+
progress: ProgressCallback = NULL_PROGRESS,
|
|
406
|
+
) -> PatchResult:
|
|
407
|
+
"""
|
|
408
|
+
Apply an emulator patch and strip Steam DRM from a game directory.
|
|
409
|
+
|
|
410
|
+
This function orchestrates a generic patching lifecycle: scanning for Steam
|
|
411
|
+
API DLLs, creating backups in a local vault, running DRM removal on
|
|
412
|
+
executables, and writing emulator-specific configuration files.
|
|
413
|
+
|
|
414
|
+
The specific emulator binaries used and the format of the configuration
|
|
415
|
+
files are determined by the injected ``vendor`` and the ``config_writer``.
|
|
416
|
+
|
|
417
|
+
Parameters
|
|
418
|
+
----------
|
|
419
|
+
game:
|
|
420
|
+
The ``ResolvedGame`` metadata containing AppID and secondary data.
|
|
421
|
+
game_path:
|
|
422
|
+
Root directory of the game installation to be patched.
|
|
423
|
+
vendor:
|
|
424
|
+
An injected ``VendorProvider`` that supplies the emulator binaries.
|
|
425
|
+
config_writer:
|
|
426
|
+
``ConfigWriter`` implementation that defines the emulator config
|
|
427
|
+
format and destination.
|
|
428
|
+
progress:
|
|
429
|
+
Optional progress hook for UI updates.
|
|
430
|
+
|
|
431
|
+
Returns
|
|
432
|
+
-------
|
|
433
|
+
PatchResult
|
|
434
|
+
A summary of the operation, including the vault path and patched targets.
|
|
435
|
+
|
|
436
|
+
Raises
|
|
437
|
+
------
|
|
438
|
+
PatchError
|
|
439
|
+
If no Steam API DLLs are found, I/O operations fail, or
|
|
440
|
+
configuration writing fails.
|
|
441
|
+
VaultError
|
|
442
|
+
If a backup cannot be created or a vault already exists.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
with SteamLayerClient(
|
|
446
|
+
vendor=vendor,
|
|
447
|
+
config_writer=config_writer,
|
|
448
|
+
progress=progress,
|
|
449
|
+
) as client:
|
|
450
|
+
return client.patch(game, game_path)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def fetch_dlcs(
|
|
454
|
+
appid: int,
|
|
455
|
+
*,
|
|
456
|
+
options: SteamlayerOptions | None = None,
|
|
457
|
+
allow_network: bool = True,
|
|
458
|
+
progress: ProgressCallback = NULL_PROGRESS,
|
|
459
|
+
) -> dict[int, DLCInfo]:
|
|
460
|
+
"""
|
|
461
|
+
Hydrate DLC metadata for a known AppID.
|
|
462
|
+
|
|
463
|
+
Convenience wrapper around ``SteamLayerClient.fetch_dlcs()`` for
|
|
464
|
+
one-shot use. For repeated calls or shared HTTP session lifecycle,
|
|
465
|
+
use ``SteamLayerClient`` directly.
|
|
466
|
+
|
|
467
|
+
Results are cached on disk under ``options.cache_dir`` (default:
|
|
468
|
+
``~/.steamlayer/.cache``). On a cache hit the network is never
|
|
469
|
+
touched. On a miss, the Steam Web API is queried and the result is
|
|
470
|
+
written back to disk before returning.
|
|
471
|
+
|
|
472
|
+
Parameters
|
|
473
|
+
----------
|
|
474
|
+
appid:
|
|
475
|
+
Steam AppID to look up.
|
|
476
|
+
options:
|
|
477
|
+
Resolution configuration. Controls ``cache_dir`` and
|
|
478
|
+
``dlc_cache_ttl_seconds``. Defaults to ``SteamlayerOptions()``.
|
|
479
|
+
allow_network:
|
|
480
|
+
Set to ``False`` to prevent any outbound HTTP calls. Only a
|
|
481
|
+
warm cache can produce a non-empty result in that case.
|
|
482
|
+
progress:
|
|
483
|
+
Optional progress hook for surfacing status to a UI.
|
|
484
|
+
|
|
485
|
+
Returns
|
|
486
|
+
-------
|
|
487
|
+
dict[int, DLCInfo]
|
|
488
|
+
Mapping of DLC AppID → ``DLCInfo``. ``DLCInfo.from_cache`` is
|
|
489
|
+
``True`` when the result was served from disk. Returns an empty
|
|
490
|
+
dict when no DLCs are found, the cache is cold, or the API is
|
|
491
|
+
unreachable.
|
|
492
|
+
"""
|
|
493
|
+
with SteamLayerClient(
|
|
494
|
+
options=options,
|
|
495
|
+
allow_network=allow_network,
|
|
496
|
+
progress=progress,
|
|
497
|
+
) as client:
|
|
498
|
+
return client.fetch_dlcs(appid)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class _OfflineSteamWebClient:
|
|
502
|
+
"""Satisfies the SteamWebClient interface but always returns empty data."""
|
|
503
|
+
|
|
504
|
+
def search_store(self, term: str) -> dict:
|
|
505
|
+
return {}
|
|
506
|
+
|
|
507
|
+
def get_app_details(self, appid: int) -> dict:
|
|
508
|
+
return {}
|