"""Project configuration — simvx.toml files.
Schema-driven TOML file describing a SimVX game's display, physics, input,
audio, rendering, export, and editor settings. Uses stdlib tomllib for
reading and a small hand-rolled writer for round-tripping.
Public API:
from simvx.core.project import ProjectSettings, load_project, save_project
settings = load_project("simvx.toml")
settings.name = "My Game"
save_project(settings, "simvx.toml")
"""
import logging
import tomllib
from pathlib import Path
from typing import Any
from .input.enums import JoyAxis, JoyButton, Key, MouseButton, key_to_name, name_to_keys
from .input.events import InputBinding
log = logging.getLogger(__name__)
__all__ = [
"ProjectSettings",
"ValidationError",
"load_project",
"save_project",
"find_project",
"input_bindings_from_toml",
"input_bindings_to_toml",
]
TOML_FILENAME = "simvx.toml"
# --- Input binding serialisation ---------------------------------------------
def _binding_from_entry(action: str, entry: Any) -> InputBinding:
"""Convert one [input] entry (string or dict) to an InputBinding."""
if isinstance(entry, str):
keys = name_to_keys(entry)
if keys:
return InputBinding(key=keys[0])
upper = entry.upper()
try:
return InputBinding(key=Key[upper])
except KeyError:
pass
try:
return InputBinding(mouse_button=MouseButton[upper])
except KeyError:
pass
try:
return InputBinding(joy_button=JoyButton[upper])
except KeyError:
pass
raise ValidationError(f"[input] {action}: unknown binding name {entry!r}")
if not isinstance(entry, dict):
raise ValidationError(
f"[input] {action}: binding must be a string or inline table, got {type(entry).__name__}"
)
key = entry.get("key")
mouse = entry.get("mouse")
joy_button = entry.get("joy_button")
joy_axis = entry.get("joy_axis")
provided = [n for n, v in (("key", key), ("mouse", mouse), ("joy_button", joy_button), ("joy_axis", joy_axis)) if v is not None]
if len(provided) != 1:
raise ValidationError(
f"[input] {action}: each binding must specify exactly one of key/mouse/joy_button/joy_axis"
)
if key is not None:
ks = name_to_keys(str(key))
if ks:
return InputBinding(key=ks[0])
try:
return InputBinding(key=Key[str(key).upper()])
except KeyError as exc:
raise ValidationError(f"[input] {action}: unknown key {key!r}") from exc
if mouse is not None:
try:
return InputBinding(mouse_button=MouseButton[str(mouse).upper()])
except KeyError as exc:
raise ValidationError(f"[input] {action}: unknown mouse button {mouse!r}") from exc
if joy_button is not None:
try:
return InputBinding(joy_button=JoyButton[str(joy_button).upper()])
except KeyError as exc:
raise ValidationError(f"[input] {action}: unknown joy_button {joy_button!r}") from exc
try:
axis = JoyAxis[str(joy_axis).upper()]
except KeyError as exc:
raise ValidationError(f"[input] {action}: unknown joy_axis {joy_axis!r}") from exc
positive = bool(entry.get("positive", True))
deadzone_raw = entry.get("deadzone", 0.2)
if not isinstance(deadzone_raw, int | float):
raise ValidationError(f"[input] {action}: deadzone must be a number")
deadzone = float(deadzone_raw)
if not 0.0 <= deadzone <= 1.0:
raise ValidationError(f"[input] {action}: deadzone must be between 0.0 and 1.0")
return InputBinding(joy_axis=axis, joy_axis_positive=positive, deadzone=deadzone)
def _binding_to_toml(binding: InputBinding) -> dict[str, Any]:
"""Serialise an InputBinding to an inline-table-friendly dict."""
if binding.key is not None:
return {"key": key_to_name(binding.key)}
if binding.mouse_button is not None:
return {"mouse": binding.mouse_button.name.lower()}
if binding.joy_button is not None:
return {"joy_button": binding.joy_button.name.lower()}
if binding.joy_axis is not None:
entry: dict[str, Any] = {"joy_axis": binding.joy_axis.name.lower()}
if not binding.joy_axis_positive:
entry["positive"] = False
if binding.deadzone != 0.2:
entry["deadzone"] = float(binding.deadzone)
return entry
raise ValueError("InputBinding has no active field to serialise")
# --- Schema defaults ---
_DISPLAY_DEFAULTS: dict[str, Any] = {
"width": 1280,
"height": 720,
"vsync": True,
"fullscreen": False,
"stretch_mode": "viewport",
"stretch_aspect": "keep",
}
_PHYSICS_DEFAULTS: dict[str, Any] = {
"fps": 60,
"gravity": 9.8,
}
_AUDIO_DEFAULTS: dict[str, Any] = {
"master_volume": 1.0,
}
_RENDERING_DEFAULTS: dict[str, Any] = {
"backend": "vulkan",
"msaa": 0,
}
_EXPORT_WEB_DEFAULTS: dict[str, Any] = {
"width": 800,
"height": 600,
"responsive": False,
# pyodide_version is intentionally omitted — the canonical default lives in
# simvx.web.export.DEFAULT_PYODIDE_VERSION. Project TOMLs that want to pin
# a specific version set it explicitly; otherwise the exporter supplies it.
"extra_packages": [],
"root_class": "",
"title": "SimVX",
"output_path": "dist/web/game.html",
}
_EXPORT_DESKTOP_DEFAULTS: dict[str, Any] = {
"icon": "",
"mode": "folder",
"os_label": "linux",
"build_wheel": False,
"create_zip": False,
"package_name": "",
"version": "0.1.0",
"output_dir": "dist/desktop",
}
_EXPORT_ANDROID_DEFAULTS: dict[str, Any] = {
"package": "",
"min_sdk": 26,
"mode": "debug",
"output_dir": "dist/android",
}
_EXPORT_EXE_DEFAULTS: dict[str, Any] = {
"onefile": True,
"console": False,
"name": "",
"output_dir": "dist/exe",
}
_EDITOR_DEFAULTS: dict[str, Any] = {
"plugins": [],
"class_files_dir": "src",
}
# --- Schema validation ---
_SCHEMA: dict[str, dict[str, type | tuple[type, ...]]] = {
"display": {"width": int, "height": int, "vsync": bool, "fullscreen": bool, "stretch_mode": str, "stretch_aspect": str},
"physics": {"fps": int, "gravity": (int, float)},
"audio": {"master_volume": (int, float)},
"rendering": {"backend": str, "msaa": int},
"export.web": {
"width": int, "height": int, "responsive": bool, "pyodide_version": str,
"extra_packages": list, "root_class": str, "title": str, "output_path": str,
},
"export.desktop": {
"icon": str, "mode": str, "os_label": str, "build_wheel": bool, "create_zip": bool,
"package_name": str, "version": str, "output_dir": str,
},
"export.android": {"package": str, "min_sdk": int, "mode": str, "output_dir": str},
"export.exe": {"onefile": bool, "console": bool, "name": str, "output_dir": str},
"editor": {"plugins": list, "class_files_dir": str},
}
_VALID_STRETCH_MODES = {"viewport", "canvas_items", "disabled"}
_VALID_STRETCH_ASPECTS = {"keep", "expand", "ignore"}
_VALID_BACKENDS = {"vulkan", "sdl3"}
_VALID_DESKTOP_MODES = {"wheel", "folder"}
_VALID_ANDROID_MODES = {"debug", "release"}
# Autoload names the engine reserves for itself. Mirrors the constant in
# scene_tree.py; project loading rejects these names up-front so the error
# surfaces on simvx.toml load rather than at first add_autoload() call.
_RESERVED_AUTOLOAD_NAMES = {"events"}
[docs]
class ValidationError(ValueError):
"""Raised when simvx.toml contains invalid values."""
def _validate_type(section: str, key: str, value: Any, expected: type | tuple[type, ...]) -> None:
"""Validate a single field's type."""
if not isinstance(value, expected):
exp = expected.__name__ if isinstance(expected, type) else " or ".join(t.__name__ for t in expected)
raise ValidationError(f"[{section}] {key}: expected {exp}, got {type(value).__name__}")
def _validate_section(section_name: str, data: dict[str, Any]) -> None:
"""Validate all fields in a section against the schema."""
schema = _SCHEMA.get(section_name)
if not schema:
return
for key, value in data.items():
if key in schema:
_validate_type(section_name, key, value, schema[key])
def _validate_constraints(settings: ProjectSettings) -> None:
"""Validate cross-field and enum-like constraints."""
if settings.display.get("width", 1280) <= 0:
raise ValidationError("[display] width: must be positive")
if settings.display.get("height", 720) <= 0:
raise ValidationError("[display] height: must be positive")
sm = settings.display.get("stretch_mode", "viewport")
if sm not in _VALID_STRETCH_MODES:
raise ValidationError(f"[display] stretch_mode: must be one of {_VALID_STRETCH_MODES}, got {sm!r}")
sa = settings.display.get("stretch_aspect", "keep")
if sa not in _VALID_STRETCH_ASPECTS:
raise ValidationError(f"[display] stretch_aspect: must be one of {_VALID_STRETCH_ASPECTS}, got {sa!r}")
backend = settings.rendering.get("backend", "vulkan")
if backend not in _VALID_BACKENDS:
raise ValidationError(f"[rendering] backend: must be one of {_VALID_BACKENDS}, got {backend!r}")
fps = settings.physics.get("fps", 60)
if fps <= 0:
raise ValidationError("[physics] fps: must be positive")
mv = settings.audio.get("master_volume", 1.0)
if not 0.0 <= mv <= 1.0:
raise ValidationError("[audio] master_volume: must be between 0.0 and 1.0")
# export.web
if settings.export_web.get("width", 1) <= 0:
raise ValidationError("[export.web] width: must be positive")
if settings.export_web.get("height", 1) <= 0:
raise ValidationError("[export.web] height: must be positive")
# export.desktop
dm = settings.export_desktop.get("mode", "folder")
if dm not in _VALID_DESKTOP_MODES:
raise ValidationError(f"[export.desktop] mode: must be one of {_VALID_DESKTOP_MODES}, got {dm!r}")
# export.android
if settings.export_android.get("min_sdk", 26) < 21:
raise ValidationError("[export.android] min_sdk: must be >= 21")
am = settings.export_android.get("mode", "debug")
if am not in _VALID_ANDROID_MODES:
raise ValidationError(f"[export.android] mode: must be one of {_VALID_ANDROID_MODES}, got {am!r}")
# Validate input action values are lists of InputBindings
for action, bindings in settings.input.items():
if not isinstance(bindings, list) or not all(isinstance(b, InputBinding) for b in bindings):
raise ValidationError(f"[input] {action}: must be a list of InputBinding objects")
# Validate editor plugins is a list of strings
plugins = settings.editor.get("plugins", [])
if not isinstance(plugins, list) or not all(isinstance(p, str) for p in plugins):
raise ValidationError("[editor] plugins: must be a list of strings")
cfd = settings.editor.get("class_files_dir", "src")
if not isinstance(cfd, str) or not cfd:
raise ValidationError("[editor] class_files_dir: must be a non-empty string")
# Reject reserved autoload names. The engine ships its own autoloads
# (currently: "events" -> EventBus, exposed as tree.events). User code
# would shadow them and break access via tree.events, so refuse the load.
for reserved in sorted(_RESERVED_AUTOLOAD_NAMES.intersection(settings.autoloads)):
raise ValidationError(
f"[autoloads] {reserved!r}: name is reserved for the engine "
f"(use tree.{reserved} to access the built-in instance); "
"pick a different autoload name."
)
# --- Settings class ---
[docs]
class ProjectSettings:
"""TOML-based project configuration.
Sections are stored as plain dicts for simplicity and easy serialization.
Top-level fields (name, main) are direct attributes.
"""
__slots__ = (
"name", "main", "display", "input", "physics", "audio", "rendering",
"export_web", "export_desktop", "export_android", "export_exe", "editor",
"autoloads", "project_path", "engine_version",
)
def __init__(self, data: dict[str, Any] | None = None):
data = data or {}
self.name: str = data.get("name", "Untitled")
self.main: str = data.get("main", "")
self.display: dict[str, Any] = {**_DISPLAY_DEFAULTS, **data.get("display", {})}
raw_input = data.get("input", {}) or {}
self.input: dict[str, list[InputBinding]] = {
action: input_bindings_from_toml(action, entries) for action, entries in raw_input.items()
}
self.physics: dict[str, Any] = {**_PHYSICS_DEFAULTS, **data.get("physics", {})}
self.audio: dict[str, Any] = {**_AUDIO_DEFAULTS, **data.get("audio", {})}
self.rendering: dict[str, Any] = {**_RENDERING_DEFAULTS, **data.get("rendering", {})}
export = data.get("export", {})
self.export_web: dict[str, Any] = {**_EXPORT_WEB_DEFAULTS, **export.get("web", {})}
self.export_desktop: dict[str, Any] = {**_EXPORT_DESKTOP_DEFAULTS, **export.get("desktop", {})}
self.export_android: dict[str, Any] = {**_EXPORT_ANDROID_DEFAULTS, **export.get("android", {})}
self.export_exe: dict[str, Any] = {**_EXPORT_EXE_DEFAULTS, **export.get("exe", {})}
self.editor: dict[str, Any] = {**_EDITOR_DEFAULTS, **data.get("editor", {})}
# Engine section
engine = data.get("engine", {})
self.engine_version: str = engine.get("version", "")
self.autoloads: dict[str, str] = dict(data.get("autoloads", {}))
self.project_path: str = ""
[docs]
def to_dict(self) -> dict[str, Any]:
"""Serialize to a nested dict matching TOML structure."""
d: dict[str, Any] = {"name": self.name, "main": self.main}
d["display"] = dict(self.display)
if self.input:
d["input"] = {action: input_bindings_to_toml(bindings) for action, bindings in self.input.items()}
d["physics"] = dict(self.physics)
d["audio"] = dict(self.audio)
d["rendering"] = dict(self.rendering)
export: dict[str, Any] = {}
if self.export_web != _EXPORT_WEB_DEFAULTS:
export["web"] = dict(self.export_web)
if self.export_desktop != _EXPORT_DESKTOP_DEFAULTS:
export["desktop"] = dict(self.export_desktop)
if self.export_android != _EXPORT_ANDROID_DEFAULTS:
export["android"] = dict(self.export_android)
if self.export_exe != _EXPORT_EXE_DEFAULTS:
export["exe"] = dict(self.export_exe)
if export:
d["export"] = export
if self.editor != _EDITOR_DEFAULTS:
d["editor"] = dict(self.editor)
if self.engine_version:
d["engine"] = {"version": self.engine_version}
if self.autoloads:
d["autoloads"] = dict(self.autoloads)
return d
[docs]
def validate(self) -> None:
"""Validate all settings. Raises ValidationError on invalid values."""
_validate_section("display", self.display)
_validate_section("physics", self.physics)
_validate_section("audio", self.audio)
_validate_section("rendering", self.rendering)
_validate_section("export.web", self.export_web)
_validate_section("export.desktop", self.export_desktop)
_validate_section("export.android", self.export_android)
_validate_section("export.exe", self.export_exe)
_validate_section("editor", self.editor)
_validate_constraints(self)
[docs]
@property
def project_dir(self) -> str:
"""Directory containing the project file (``"."`` if unset)."""
if self.project_path:
return str(Path(self.project_path).parent)
return "."
[docs]
def resolve_path(self, relative: str) -> str:
"""Resolve a project-relative path to an absolute path."""
return str((Path(self.project_dir) / relative).resolve())
# --- TOML writer ---
def _toml_inline_table(d: dict[str, Any]) -> str:
"""Format a dict as a TOML inline table literal."""
parts = [f"{k} = {_toml_value(v)}" for k, v in d.items()]
return "{" + ", ".join(parts) + "}"
def _toml_value(v: Any) -> str:
"""Format a Python value as a TOML literal."""
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, int):
return str(v)
if isinstance(v, float):
return f"{v:g}"
if isinstance(v, str):
# Escape backslashes and quotes
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
if isinstance(v, dict):
return _toml_inline_table(v)
if isinstance(v, list):
items = ", ".join(_toml_value(item) for item in v)
return f"[{items}]"
raise TypeError(f"Unsupported TOML type: {type(v).__name__}")
def _write_toml(data: dict[str, Any]) -> str:
"""Generate valid TOML from a nested dict.
Handles: str, int, float, bool, list[str], and one level of nested tables.
"""
lines: list[str] = []
# Top-level scalar keys first
for key, value in data.items():
if not isinstance(value, dict):
lines.append(f"{key} = {_toml_value(value)}")
# Then sections (dicts)
for key, value in data.items():
if isinstance(value, dict):
# Check if this is a table with sub-tables (like export.web)
has_subtables = any(isinstance(v, dict) for v in value.values())
if has_subtables:
# Write scalar keys under [key] first
scalars = {k: v for k, v in value.items() if not isinstance(v, dict)}
if scalars:
lines.append("")
lines.append(f"[{key}]")
for sk, sv in scalars.items():
lines.append(f"{sk} = {_toml_value(sv)}")
# Then sub-tables as [key.subkey]
for sk, sv in value.items():
if isinstance(sv, dict):
lines.append("")
lines.append(f"[{key}.{sk}]")
for ssk, ssv in sv.items():
lines.append(f"{ssk} = {_toml_value(ssv)}")
else:
lines.append("")
lines.append(f"[{key}]")
for sk, sv in value.items():
lines.append(f"{sk} = {_toml_value(sv)}")
return "\n".join(lines) + "\n"
# --- Public API ---
[docs]
def load_project(path: str | Path) -> ProjectSettings:
"""Load project settings from a simvx.toml file.
Raises:
FileNotFoundError: If the file does not exist.
tomllib.TOMLDecodeError: If the file is not valid TOML.
ValidationError: If values fail schema validation.
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Project file not found: {path}")
data = tomllib.loads(path.read_text(encoding="utf-8"))
settings = ProjectSettings(data)
settings.validate()
settings.project_path = str(path.resolve())
log.debug("project: loaded %s (%s)", settings.name, path)
return settings
[docs]
def save_project(settings: ProjectSettings, path: str | Path | None = None) -> None:
"""Save project settings to a simvx.toml file.
Args:
settings: ProjectSettings to save.
path: Output file path. If None, uses settings.project_path.
"""
if path is None:
path = settings.project_path
if not path:
raise ValueError("No path specified and settings.project_path is empty")
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_write_toml(settings.to_dict()), encoding="utf-8")
settings.project_path = str(path.resolve())
log.debug("project: saved %s to %s", settings.name, path)
[docs]
def find_project(start_dir: str | Path | None = None) -> Path | None:
"""Search up the directory tree for simvx.toml.
Args:
start_dir: Directory to start searching from. Defaults to cwd.
Returns:
Path to simvx.toml if found, None otherwise.
"""
current = Path(start_dir or ".").resolve()
while True:
candidate = current / TOML_FILENAME
if candidate.is_file():
return candidate
parent = current.parent
if parent == current:
return None
current = parent