Skip to content
This repository was archived by the owner on May 11, 2026. It is now read-only.

Commit 8712e88

Browse files
authored
Merge pull request #1 from layeredtools/feat/config-system
Better configs
2 parents d20e30a + bf3d2d8 commit 8712e88

17 files changed

Lines changed: 695 additions & 220 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "steamlayer"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
description = "Patch Steam games to use the Goldberg emulator — replaces Steam API DLLs, writes DLC config, and restores originals on demand."
55
readme = "README.md"
66
license = "MIT"
@@ -9,6 +9,7 @@ requires-python = ">=3.13"
99
dependencies = [
1010
"requests>=2.33.1",
1111
"rich>=15.0.0",
12+
"tomli-w>=1.2.0",
1213
]
1314
classifiers = [
1415
"Development Status :: 3 - Alpha",

steamlayer/config/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
from .defaults import (
4+
DEFAULTS as DEFAULTS,
5+
)
6+
from .defaults import (
7+
VALID_LANGUAGES as VALID_LANGUAGES,
8+
)
9+
from .defaults import (
10+
ConfigError as ConfigError,
11+
)
12+
from .resolver import ConfigResolver as ConfigResolver
13+
from .writer import _atomic_write_toml as _atomic_write_toml
14+
from .writer import _build_game_config_payload as _build_game_config_payload
15+
from .writer import write_game_config as write_game_config

steamlayer/config/defaults.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
from typing import TypedDict
4+
5+
6+
class ConfigDict(TypedDict):
7+
steamlayer: SteamlayerConfigDict
8+
goldberg: GoldbergConfigDict
9+
10+
11+
class SteamlayerConfigDict(TypedDict):
12+
strict: bool
13+
verbose: int
14+
no_network: bool
15+
no_defender_check: bool
16+
dry_run: bool
17+
unpack: bool
18+
19+
20+
class GoldbergConfigDict(TypedDict):
21+
account_name: str
22+
language: str
23+
legacy_dlcs: bool
24+
unlock_all_dlcs: bool
25+
write_steam_appid: bool
26+
preserve_user_cfg: bool
27+
28+
29+
DEFAULTS: ConfigDict = {
30+
"steamlayer": {
31+
"strict": True,
32+
"verbose": 0,
33+
"no_network": False,
34+
"no_defender_check": False,
35+
"dry_run": False,
36+
"unpack": False,
37+
},
38+
"goldberg": {
39+
# User-visible identity
40+
"account_name": "Player", # string
41+
# Language used in configs.user.ini (must be a known value)
42+
"language": "english", # string, must be in VALID_LANGUAGES
43+
# DLC handling
44+
"legacy_dlcs": False, # bool: write DLC.txt when True, configs.app.ini when False
45+
"unlock_all_dlcs": False, # bool: when True, configs.app.ini: unlock_all = 1
46+
# Steam ID file behaviour
47+
"write_steam_appid": True, # bool: write steam_appid.txt alongside config dirs and root
48+
# Overwrite policy (we default to always overwrite, i.e. False = don't preserve)
49+
"preserve_user_cfg": False, # bool: if True generator would attempt to keep
50+
# configs.user.ini (we will ignore if you want always overwrite)
51+
# Optional explicit DLC map (useful to persist choices in .steamlayer.toml)
52+
},
53+
}
54+
55+
VALID_LANGUAGES = {
56+
"english",
57+
"french",
58+
"german",
59+
"spanish",
60+
"italian",
61+
"portuguese",
62+
"russian",
63+
"korean",
64+
"japanese",
65+
"chinese",
66+
"thai",
67+
"bulgarian",
68+
"czech",
69+
"danish",
70+
"dutch",
71+
"finnish",
72+
"greek",
73+
"hungarian",
74+
"norwegian",
75+
"polish",
76+
"romanian",
77+
"swedish",
78+
"turkish",
79+
"ukrainian",
80+
"vietnamese",
81+
"traditional_chinese",
82+
"simplified_chinese",
83+
}
84+
85+
86+
class ConfigError(Exception):
87+
"""Raised when configuration validation fails."""
88+
89+
pass

steamlayer/config/resolver.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import pathlib
5+
import tomllib
6+
from typing import Any, cast
7+
8+
from steamlayer import TOOL_HOME
9+
10+
from .defaults import DEFAULTS, VALID_LANGUAGES, ConfigError
11+
12+
log = logging.getLogger("steamlayer.config.resolver")
13+
14+
15+
class ConfigResolver:
16+
def __init__(self, game_dir: pathlib.Path | None = None) -> None:
17+
self._game_dir = game_dir
18+
self._global_config = self._load_toml(TOOL_HOME / "config.toml")
19+
self._game_config = self._load_toml(self._game_dir / ".steamlayer.toml") if self._game_dir else {}
20+
self._merged_config = self.deep_merge(self._global_config, self._game_config)
21+
self.validate(self._merged_config)
22+
23+
def _load_toml(self, path: pathlib.Path | None) -> dict[str, Any]:
24+
if not path or not path.exists():
25+
return {}
26+
try:
27+
with open(path, "rb") as f:
28+
return tomllib.load(f)
29+
except Exception as e:
30+
log.warning(f"Could not load config from '{path}': {e}")
31+
return {}
32+
33+
def deep_merge(self, base: dict, override: dict) -> dict:
34+
"""
35+
Recursively merge override into base, respecting section boundaries.
36+
37+
Each top-level section ([steamlayer], [goldberg], etc.) is merged
38+
independently — a missing [goldberg] in override doesn't erase base [goldberg].
39+
"""
40+
result = {k: v.copy() if isinstance(v, dict) else v for k, v in base.items()}
41+
for key, val in override.items():
42+
if key in result and isinstance(result[key], dict) and isinstance(val, dict):
43+
result[key].update(val)
44+
else:
45+
result[key] = val
46+
return result
47+
48+
def validate(self, cfg: dict) -> None:
49+
"""
50+
Validate configuration structure and values.
51+
52+
Raises ConfigError if:
53+
- Unknown keys present in [steamlayer] or [goldberg]
54+
- Type mismatch for any known key
55+
- Unrecognised language value in [goldberg]
56+
"""
57+
steamlayer_cfg = cfg.get("steamlayer", {})
58+
goldberg_cfg = cfg.get("goldberg", {})
59+
sl_defaults: dict[str, Any] = cast(dict[str, Any], DEFAULTS["steamlayer"])
60+
gb_defaults: dict[str, Any] = cast(dict[str, Any], DEFAULTS["goldberg"])
61+
62+
valid_sl_keys = set(sl_defaults.keys())
63+
unknown_sl = set(steamlayer_cfg.keys()) - valid_sl_keys
64+
if unknown_sl:
65+
raise ConfigError(f"Unknown keys in [steamlayer]: {unknown_sl}")
66+
67+
for key, val in steamlayer_cfg.items():
68+
expected_type = type(sl_defaults[key])
69+
if not isinstance(val, expected_type):
70+
raise ConfigError(
71+
f"[steamlayer] {key}: expected {expected_type.__name__}, got {type(val).__name__}"
72+
)
73+
74+
valid_gb_keys = set(gb_defaults.keys())
75+
unknown_gb = set(goldberg_cfg.keys()) - valid_gb_keys
76+
if unknown_gb:
77+
raise ConfigError(f"Unknown keys in [goldberg]: {unknown_gb}")
78+
79+
for key, val in goldberg_cfg.items():
80+
if key == "dlcs":
81+
continue
82+
83+
expected_type = type(gb_defaults[key])
84+
if not isinstance(val, expected_type):
85+
raise ConfigError(f"[goldberg] {key}: expected {expected_type.__name__}, got {type(val).__name__}")
86+
87+
language = goldberg_cfg.get("language", gb_defaults["language"])
88+
if language.lower() not in VALID_LANGUAGES:
89+
raise ConfigError(
90+
f"[goldberg] language '{language}' not recognized. "
91+
f"Valid options: {', '.join(sorted(VALID_LANGUAGES))}"
92+
)
93+
94+
log.debug("Configuration validation passed.")
95+
96+
def resolve_config(self) -> dict[str, Any]:
97+
"""
98+
Return the fully-resolved config: DEFAULTS < global config < game config.
99+
100+
1. Start from DEFAULTS for every known section.
101+
2. Overlay the already-merged (global + game) config on top.
102+
3. Return.
103+
104+
The result is guaranteed to contain every key in DEFAULTS, which means
105+
callers can safely do cfg["steamlayer"]["dry_run"] without KeyError even
106+
if the user's config file is empty or absent.
107+
108+
Validation was already performed during __init__; a ConfigError raised
109+
there will have prevented construction, so by the time this method is
110+
called the config is known-good.
111+
"""
112+
result: dict[str, Any] = {}
113+
flat_defaults = cast(dict[str, dict[str, Any]], DEFAULTS)
114+
for section, section_defaults in flat_defaults.items():
115+
result[section] = dict(section_defaults)
116+
merged_section = self._merged_config.get(section, {})
117+
result[section].update(merged_section)
118+
119+
log.debug("Resolved config: %s", result)
120+
return result

steamlayer/config/writer.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import pathlib
5+
from datetime import datetime
6+
from typing import TYPE_CHECKING
7+
8+
import tomli_w
9+
10+
from steamlayer import __version__
11+
12+
if TYPE_CHECKING:
13+
from steamlayer.emulators import EmulatorConfig
14+
15+
log = logging.getLogger("steamlayer.config.writer")
16+
17+
18+
def _atomic_write_toml(payload: dict, dest: pathlib.Path) -> None:
19+
dest.parent.mkdir(parents=True, exist_ok=True)
20+
tmp = dest.with_name(dest.name + ".tmp")
21+
with open(tmp, "wb") as f:
22+
tomli_w.dump(payload, f, indent=2)
23+
24+
tmp.replace(dest)
25+
26+
27+
def _build_game_config_payload(
28+
*,
29+
appid: int | None,
30+
config: EmulatorConfig,
31+
dlcs: dict[str | int, str],
32+
unpack: bool,
33+
config_created: bool,
34+
) -> dict:
35+
return {
36+
"appid": appid,
37+
"goldberg": {
38+
"account_name": getattr(config, "account_name", None),
39+
"language": getattr(config, "language", None),
40+
},
41+
"dlcs": {str(k): v for k, v in dlcs.items()},
42+
"patch": {
43+
"unpack": unpack,
44+
"config_created": config_created,
45+
},
46+
"meta": {
47+
"created_by": "steamlayer",
48+
"steamlayer_version": __version__,
49+
"created_at": datetime.utcnow().isoformat() + "Z",
50+
},
51+
}
52+
53+
54+
def write_game_config(
55+
game_path: pathlib.Path,
56+
*,
57+
appid: int | None,
58+
config: EmulatorConfig,
59+
dlcs: dict[str | int, str],
60+
unpack: bool,
61+
config_created: bool,
62+
) -> None:
63+
"""
64+
Write `.steamlayer.toml` to game_path.
65+
66+
Owns the payload structure entirely — callers pass typed args,
67+
not raw dicts. This is the single place that knows what goes
68+
into the file and in what shape.
69+
"""
70+
payload = _build_game_config_payload(
71+
appid=appid,
72+
config=config,
73+
dlcs=dlcs,
74+
unpack=unpack,
75+
config_created=config_created,
76+
)
77+
dest = game_path / ".steamlayer.toml"
78+
_atomic_write_toml(payload, dest)
79+
log.info(f"Wrote .steamlayer.toml to '{game_path}'.")

0 commit comments

Comments
 (0)