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.
@@ -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,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("steamlayer-core")
5
+ except PackageNotFoundError:
6
+ __version__ = "dev"
@@ -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 {}