Files
TotenKnopfandClaude Fable 5 1001abf57a feat: v4.1.0 -- workshop-ready (logo, path discovery, docs)
Players install from Steam Workshop instead of install.sh, which failed on
Windows (bash) and a Manjaro box (hard-coded Mods globs). Adds Logo +
logo.png the uploader ships, tools/steam_paths.py reading Steam's
libraryfolders.vdf (Flatpak/Snap/second disk/Windows), and README install
tiers plus in-game publishing steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TkQMhvEyoFqKzeCC69N6o
2026-08-29 17:10:22 +02:00

783 lines
31 KiB
Python

# ABOUT ME
# Validation suite for the Auto Radiator mod.
#
# Why this exists: Cosmoteer applies a mod's "Actions" to vanilla data at load
# time. A path that does not resolve, a component reference that points at
# nothing, or a misspelled component Type all fail the same way -- the game
# crashes on startup and leaves a log file. These tests catch all three offline,
# against the player's actual installed game data, before the game is launched.
#
# Run: python3 tests/test_mod.py
# The game install is found through Steam's own library list (tools/steam_paths.py),
# or set COSMOTEER_DATA to override.
import os
import re
import sys
MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(MOD_ROOT, "tools"))
import rules_parser
import steam_paths
# Component fields whose value names another component in the same part.
COMPONENT_REF_FIELDS = {
"OperationalToggle",
"Toggle",
"ToggleSource",
"ValueFrom",
"ValueSource",
"ResourceStorage",
"StartTrigger",
"StopTrigger",
"ResetTrigger",
"OnTrigger",
"OffTrigger",
"Trigger",
"FromStorage",
"ToStorage",
}
_failures = []
_checks = 0
def check(condition, message):
global _checks
_checks += 1
if not condition:
_failures.append(message)
def find_data_dir():
"""Locate the installed game's Data folder via tools/steam_paths.py."""
return steam_paths.find_data_dir()
DATA = find_data_dir()
_vanilla_cache = {}
def vanilla(rel_path):
"""Parse a vanilla .rules file, relative to the Data folder."""
if rel_path not in _vanilla_cache:
full = os.path.join(DATA, rel_path)
_vanilla_cache[rel_path] = rules_parser.parse_file(full) if os.path.isfile(full) else None
return _vanilla_cache[rel_path]
def split_target(target):
"""Split '<file.rules>/A/B' into ('file.rules', 'A/B')."""
match = re.match(r"^<([^>]+)>/?(.*)$", target.strip().strip('"'))
if not match:
return None, None
return match.group(1), match.group(2)
def resolve(target):
"""Resolve a mod action target against vanilla data. Returns a Node or None."""
rel_path, inner = split_target(target)
if rel_path is None:
return None
root = vanilla(rel_path)
if root is None:
return None
return root.get(inner) if inner else root
# ---------------------------------------------------------------------------
# 1. Parser self-check, against facts read directly out of the vanilla files.
# If these fail the parser is broken and every later result is meaningless.
# ---------------------------------------------------------------------------
def test_parser_reads_vanilla_correctly():
radiator = vanilla("ships/terran/radiator/radiator.rules")
check(radiator is not None, "cannot read vanilla radiator.rules")
if radiator is None:
return
power = radiator.get("Part/Components/PowerToggle")
check(power is not None, "radiator PowerToggle not found by parser")
check(
power is not None and power.get("Type").value == "UIToggle",
"radiator PowerToggle should be a UIToggle",
)
converter = radiator.get("Part/Components/HeatDissipationConverter")
check(converter is not None, "radiator HeatDissipationConverter not found")
check(
converter is not None and converter.get("OperationalToggle").value == "PowerToggle",
"vanilla HeatDissipationConverter should gate on PowerToggle",
)
battery = vanilla("ships/terran/thermal_battery/thermal_battery.rules")
storage = battery.get("Part/Components/HeatStorage") if battery else None
check(storage is not None, "thermal battery HeatStorage not found")
check(
storage is not None and storage.get("Type").value == "ResourceStorage",
"thermal battery HeatStorage should be a ResourceStorage",
)
overclock = vanilla("ships/base_part_overclock.rules")
overflow = overclock.get("Part/Components/OverflowHeatStorage") if overclock else None
check(overflow is not None, "base_part_overclock OverflowHeatStorage not found")
check(
overflow is not None and overflow.get("Type").value == "ResourceConsumingStorage",
"OverflowHeatStorage should be a ResourceConsumingStorage",
)
# ---------------------------------------------------------------------------
# 2. Every path the mod's actions touch must exist in vanilla data.
# ---------------------------------------------------------------------------
def iter_actions(mod):
actions = mod.get("Actions")
if actions is None:
return []
return [actions.children[key] for key in actions.order]
def test_action_targets_resolve():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
actions = iter_actions(mod)
check(len(actions) > 0, "mod.rules declares no Actions")
for action in actions:
kind = action.get("Action")
kind = kind.value if kind else "?"
for field in ("Replace", "OverrideIn", "AddTo", "AddBaseTo", "Remove"):
node = action.get(field)
if node is None or node.kind != "scalar":
continue
target = node.value
check(
resolve(target) is not None,
f"{kind} action target does not exist in vanilla data: {target}",
)
# ---------------------------------------------------------------------------
# 3. Component references must resolve.
# A component the mod adds may reference its own siblings or any component
# already on the vanilla part it is being merged into.
# ---------------------------------------------------------------------------
def added_component_sets():
"""Yield (mod_file, vanilla_components_node) for each AddBase action."""
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
for action in iter_actions(mod):
kind = action.get("Action")
if not kind or kind.value != "AddBase":
continue
target_node = action.get("AddBaseTo")
base_node = action.get("BaseToAdd")
if target_node is None or base_node is None:
continue
base_ref = base_node.value.strip()
match = re.match(r"^&?<([^>]+)>$", base_ref)
check(match is not None, f"cannot parse BaseToAdd reference: {base_ref}")
if match is None:
continue
mod_file = os.path.join(MOD_ROOT, match.group(1))
check(os.path.isfile(mod_file), f"BaseToAdd file missing: {match.group(1)}")
if not os.path.isfile(mod_file):
continue
yield mod_file, resolve(target_node.value)
def test_component_references_resolve():
for mod_file, vanilla_components in added_component_sets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
check(
vanilla_components is not None,
f"{name}: AddBaseTo target missing from vanilla data",
)
if vanilla_components is None:
continue
known = set(added.order) | set(vanilla_components.order)
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
for field in component.order:
if field not in COMPONENT_REF_FIELDS:
continue
value = component.children[field]
if value.kind != "scalar":
continue
ref = value.value.strip()
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", ref):
continue # a path or expression, not a plain sibling name
check(
ref in known,
f"{name}: {component_name}.{field} references unknown component '{ref}'",
)
toggles = component.get("Toggles")
if toggles is not None and toggles.kind == "list":
for key in toggles.order:
entry = toggles.children[key]
if entry.kind == "group":
# Inline form: { Toggle = Something; Invert = true }
inner = entry.get("Toggle")
ref = inner.value.strip() if inner is not None else None
check(
ref is not None,
f"{name}: {component_name}.Toggles has an inline entry with no Toggle",
)
else:
ref = entry.value.strip()
if ref is None:
continue
check(
ref in known,
f"{name}: {component_name}.Toggles references unknown component '{ref}'",
)
# ---------------------------------------------------------------------------
# 4. Every component Type the mod uses must be a Type the engine already knows,
# proven by it appearing somewhere in vanilla data. Catches misspellings.
# ---------------------------------------------------------------------------
_vanilla_types = None
def vanilla_types():
global _vanilla_types
if _vanilla_types is None:
found = set()
pattern = re.compile(r"Type\s*=\s*([A-Za-z_][A-Za-z0-9_]*)")
for root, _dirs, files in os.walk(DATA):
for filename in files:
if not filename.endswith(".rules"):
continue
with open(os.path.join(root, filename), "r", encoding="utf-8", errors="replace") as handle:
found.update(pattern.findall(handle.read()))
_vanilla_types = found
return _vanilla_types
def test_component_types_exist():
for mod_file, _ in added_component_sets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
type_node = component.get("Type")
check(type_node is not None, f"{name}: {component_name} has no Type")
if type_node is None:
continue
check(
type_node.value in vanilla_types(),
f"{name}: {component_name} uses unknown component Type '{type_node.value}'",
)
# ---------------------------------------------------------------------------
# 5. Signals must use a mechanism that works on a physical ship.
# Regression guard: version 1.0.0 broadcast with NetworkToggle, which loaded
# without error and did nothing in game. Every NetworkToggle and
# NetworkToggleReceiver in vanilla sets IncludeInPhysical = false -- they are
# blueprint stat-display plumbing only. NetworkValue carries the signal on a
# real ship, and is what vanilla's thermal amplification pump uses.
# ---------------------------------------------------------------------------
BLUEPRINT_ONLY_TYPES = {"NetworkToggle", "NetworkToggleReceiver"}
def test_no_blueprint_only_signal_types():
for mod_file, _ in added_component_sets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
type_node = component.get("Type")
if type_node is None:
continue
check(
type_node.value not in BLUEPRINT_ONLY_TYPES,
f"{name}: {component_name} uses '{type_node.value}', which vanilla only ever "
f"uses with IncludeInPhysical = false and which carries no signal in game",
)
def test_vanilla_marks_those_types_blueprint_only():
"""The premise of the guard above, re-checked against the installed game."""
pattern = re.compile(
r"Type\s*=\s*(NetworkToggle|NetworkToggleReceiver)\b(.*?)(?=\n\s*\})",
re.DOTALL,
)
sites = 0
physical_false = 0
for root, _dirs, files in os.walk(DATA):
for filename in files:
if not filename.endswith(".rules"):
continue
with open(os.path.join(root, filename), "r", encoding="utf-8", errors="replace") as handle:
for match in pattern.finditer(handle.read()):
sites += 1
if "IncludeInPhysical = false" in match.group(2):
physical_false += 1
check(sites > 0, "expected vanilla to use NetworkToggle somewhere")
check(
sites == physical_false,
f"premise broken: {sites - physical_false} of {sites} vanilla NetworkToggle sites "
f"are physical, so the guard in test_no_blueprint_only_signal_types may be wrong",
)
# ---------------------------------------------------------------------------
# 6. Added components must not silently overwrite a vanilla component.
# ---------------------------------------------------------------------------
def test_no_name_collisions_with_vanilla():
for mod_file, vanilla_components in added_component_sets():
if vanilla_components is None:
continue
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
for component_name in added.order:
check(
component_name not in vanilla_components.children,
f"{name}: '{component_name}' already exists on the vanilla part and would "
f"be overwritten",
)
# ---------------------------------------------------------------------------
# 6b. Every field written on a component must exist in that component's real
# schema, read out of the game assembly's metadata.
#
# Cosmoteer ignores fields it does not recognise rather than reporting them,
# so a misspelled or invented field name produces a mod that loads perfectly
# and silently does nothing. This is the only check that catches that.
# ---------------------------------------------------------------------------
ASSEMBLY_CANDIDATES = [
os.path.join(os.path.dirname(DATA or ""), "Bin", "Cosmoteer.dll") if DATA else "",
]
def find_assembly():
override = os.environ.get("COSMOTEER_ASSEMBLY")
if override and os.path.isfile(override):
return override
for path in ASSEMBLY_CANDIDATES:
if path and os.path.isfile(path):
return path
return None
def test_component_fields_exist_in_schema():
assembly = find_assembly()
if assembly is None:
print(" (skipped: Cosmoteer.dll not found)")
return
sys.path.insert(0, os.path.join(MOD_ROOT, "tools"))
import dotnet_enum
tables = dotnet_enum.load(assembly)
for mod_file, _ in added_component_sets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
type_node = component.get("Type")
if type_node is None:
continue
schema = dotnet_enum.schema(tables, f"Part{type_node.value}Rules")
check(schema is not None, f"{name}: no schema found for Type '{type_node.value}'")
if schema is None:
continue
# Some members are complex objects the serializer accepts inline,
# so their own fields are legal at the component's top level too.
for member, inline_type in (("NetworkFilter", "PartNetworkFilter"),):
if member in schema:
nested = dotnet_enum.schema(tables, inline_type)
if nested:
schema = schema | nested
for field in component.order:
if field == "Type":
continue
# ALL_CAPS entries are local constants for &references, the
# vanilla convention (thermal_battery.rules: MAX_STATUS_HEAT).
if field.isupper():
continue
check(
field in schema,
f"{name}: {component_name} ({type_node.value}) has no field "
f"'{field}' -- it would be silently ignored",
)
# ---------------------------------------------------------------------------
# 6c. Every network component the mod adds must be reachable by a route.
#
# A NetworkValue or NetworkValueReceiver is only attached to the network if
# a route leads to it: the part's NetworkPort anchors on one component, and
# a NetworkRouter bridges that anchor to the rest. An unrouted network
# component is not an error -- it simply never connects and always reads as
# absent. That is exactly how v1.1.0 and v1.2.0 failed: they loaded clean,
# the components existed on the parts, and every signal read zero.
# ---------------------------------------------------------------------------
NETWORK_COMPONENT_TYPES = {"NetworkValue", "NetworkValueReceiver", "NetworkResourceInput"}
def collect_route_names(node, found=None):
"""Every component name appearing in any Routes list under this node."""
if found is None:
found = set()
if node.kind not in ("group", "list"):
return found
for name in node.order:
child = node.children[name]
if name == "Routes" and child.kind == "list":
for key in child.order:
route = child.children[key]
if route.kind == "list":
for slot in route.order:
value = route.children[slot]
if value.kind == "scalar":
found.add(value.value.strip())
collect_route_names(child, found)
return found
def addbase_targets():
"""Yield (mod_file_path, target_part_rules_path) for each AddBase action."""
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
for action in iter_actions(mod):
kind = action.get("Action")
if not kind or kind.value != "AddBase":
continue
target = action.get("AddBaseTo")
base = action.get("BaseToAdd")
if target is None or base is None:
continue
match = re.match(r"^&?<([^>]+)>$", base.value.strip())
if match is None:
continue
part_path, _inner = split_target(target.value)
yield os.path.join(MOD_ROOT, match.group(1)), part_path
def test_network_components_are_routed():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
# Routes the mod contributes, keyed by the part they land on.
routed = {}
for mod_file, part_path in addbase_targets():
added = rules_parser.parse_file(mod_file)
routed.setdefault(part_path, set()).update(collect_route_names(added))
for action in iter_actions(mod):
kind = action.get("Action")
add_to = action.get("AddTo")
if not kind or kind.value != "AddMany" or add_to is None:
continue
if "Routes" not in add_to.value:
continue
part_path, _inner = split_target(add_to.value)
entries = action.get("ManyToAdd")
if entries is None:
continue
for key in entries.order:
route = entries.children[key]
if route.kind != "list":
continue
for slot in route.order:
value = route.children[slot]
if value.kind == "scalar":
routed.setdefault(part_path, set()).add(value.value.strip())
for mod_file, part_path in addbase_targets():
added = rules_parser.parse_file(mod_file)
name = os.path.basename(mod_file)
reachable = routed.get(part_path, set())
for component_name in added.order:
component = added.children[component_name]
if component.kind != "group":
continue
type_node = component.get("Type")
if type_node is None or type_node.value not in NETWORK_COMPONENT_TYPES:
continue
check(
component_name in reachable,
f"{name}: {component_name} ({type_node.value}) is not named in any route, "
f"so it never attaches to the network and always reads as absent",
)
# ---------------------------------------------------------------------------
# 6d. An Overrides value that names a component must name one that exists.
#
# The whole point of the Overrides actions is to repoint vanilla components
# at components this mod adds. If such a value names something that is not
# there -- deleted, renamed, or never written -- the mod still loads and the
# gate silently reverts to doing nothing.
# ---------------------------------------------------------------------------
def test_override_values_name_real_components():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
# What each part offers: its vanilla components plus what the mod adds.
available = {}
for mod_file, part_path in addbase_targets():
added = rules_parser.parse_file(mod_file)
vanilla_components = resolve(f"<{part_path}>/Part/Components")
names = set(added.order)
if vanilla_components is not None:
names |= set(vanilla_components.order)
available.setdefault(part_path, set()).update(names)
for action in iter_actions(mod):
kind = action.get("Action")
target = action.get("OverrideIn")
if not kind or kind.value != "Overrides" or target is None:
continue
part_path, _inner = split_target(target.value)
known = available.get(part_path)
if not known:
continue
overrides = action.get("Overrides")
if overrides is None:
continue
for field in overrides.order:
value = overrides.children[field]
if value.kind != "scalar":
continue
ref = value.value.strip()
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", ref):
continue
check(
ref in known,
f"Overrides sets {field} = '{ref}' on {os.path.basename(part_path)}, "
f"but no such component exists on that part",
)
# ---------------------------------------------------------------------------
# 7. Added indicators must bind to a real toggle and a real atlas sprite.
# ---------------------------------------------------------------------------
def test_added_indicators_are_valid():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
radiator_components = resolve("<ships/terran/radiator/radiator.rules>/Part/Components")
added = rules_parser.parse_file(os.path.join(MOD_ROOT, "components", "radiator_auto.rules"))
known = set(added.order) | set(radiator_components.order if radiator_components else [])
atlas = vanilla("gui/game/indicators/indicators.rules")
check(atlas is not None, "cannot read the indicators atlas")
for action in iter_actions(mod):
kind = action.get("Action")
add_to = action.get("AddTo")
if not kind or kind.value != "AddMany" or add_to is None:
continue
if "Indicators" not in add_to.value:
continue
entries = action.get("ManyToAdd")
check(entries is not None, "indicator AddMany has no ManyToAdd list")
if entries is None:
continue
for key in entries.order:
entry = entries.children[key]
toggle = entry.get("Toggle")
check(toggle is not None, "an added indicator has no Toggle")
if toggle is not None:
check(
toggle.value.strip() in known,
f"added indicator binds to unknown component '{toggle.value.strip()}'",
)
sprite = entry.get("AtlasSprite/File")
check(sprite is not None, "an added indicator has no AtlasSprite/File")
if sprite is not None and atlas is not None:
name = sprite.value.strip().rsplit("/", 1)[-1]
check(
name in atlas.children,
f"added indicator uses unknown atlas sprite '{name}'",
)
# ---------------------------------------------------------------------------
# 8. The mod manifest must declare the fields Cosmoteer requires.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Threshold amounts must live on the same scale as the value they read.
#
# From Cosmoteer.dll (see tools/ildump.py): PartThresholdToggle compares
# ValueFrom.Value against MinAmount/MaxAmount as plain floats -- "5%" is 0.05,
# nothing more. A ResourceStorage, NetworkResourceStore or NetworkResourceInput
# with the default ProvidedValueRange reports a 0..1 fraction (fill for
# storages, free space for inputs). v2.1.0 compared such a fraction against
# 2000000, so its "space low" toggle was permanently on and the mod was a no-op.
# ---------------------------------------------------------------------------
FRACTION_VALUED_TYPES = {"ResourceStorage", "NetworkResourceStore", "NetworkResourceInput"}
def scalar_number(node, scope=None):
"""Turn a scalar like '5%', '0.25' or '&LOCAL_CONSTANT' into a float.
A leading '&NAME' is looked up in the given scope (the component itself),
the vanilla convention for local constants. Returns None if not numeric.
"""
if node is None:
return None
text = node.value.strip()
if text.startswith("&") and scope is not None:
return scalar_number(scope.get(text[1:].strip("()")), None)
try:
if text.endswith("%"):
return float(text[:-1]) / 100.0
return float(text)
except ValueError:
return None
def test_threshold_amounts_match_value_scale():
check(scalar_number(rules_parser.Node("scalar", "5%")) == 0.05, "'5%' should read as 0.05")
scope = rules_parser.parse("K = 40%\nX = &K")
check(scalar_number(scope.get("X"), scope) == 0.4, "'&K' should resolve through the scope")
for mod_file, target in added_component_sets():
added = rules_parser.parse_file(mod_file)
for name in added.order:
comp = added.get(name)
if comp.kind != "group" or comp.get("Type") is None:
continue
if comp.get("Type").value != "ThresholdToggle":
continue
source_name = comp.get("ValueFrom").value if comp.get("ValueFrom") else None
source = added.get(source_name) if source_name else None
if source is None and target is not None:
source = target.get(source_name)
if source is None or source.get("Type") is None:
continue
source_type = source.get("Type").value
if source_type not in FRACTION_VALUED_TYPES:
continue
if source.get("ProvidedValueRange") is not None or source.get("ProvidedValuePerResource") is not None:
continue
for field in ("MinAmount", "MaxAmount"):
amount = scalar_number(comp.get(field), comp)
if amount is None:
continue
check(
0.0 <= amount <= 1.0,
f"{os.path.basename(mod_file)}: {name}.{field} = {amount} but "
f"{source_name} ({source_type}) reports a 0..1 fraction",
)
# ---------------------------------------------------------------------------
# The gate's semantics: radiator button ON = vanilla, always vent; button OFF
# = bank heat, and the mod vents only when the network is nearly full. So the
# gate is PowerToggle OR MustVent, and nothing else -- no ammo criterion, no
# launcher patch, no cross-part signal.
# ---------------------------------------------------------------------------
def test_gate_is_power_or_must_vent():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
for action in iter_actions(mod):
for field in ("AddBaseTo", "OverrideIn", "AddTo"):
node = action.get(field)
check(
node is None or "missile_launcher" not in node.value,
f"mod.rules still patches the missile launcher via {field}",
)
check(
not os.path.exists(os.path.join(MOD_ROOT, "components", "launcher_ammo_signal.rules")),
"components/launcher_ammo_signal.rules should be gone",
)
auto = rules_parser.parse_file(os.path.join(MOD_ROOT, "components", "radiator_auto.rules"))
gate = auto.get("AutoPowerToggle")
check(gate is not None, "AutoPowerToggle missing")
if gate is None:
return
check(gate.get("Mode") is not None and gate.get("Mode").value == "Any", "AutoPowerToggle must be Mode = Any (OR)")
toggles = gate.get("Toggles")
names = [c.value for c in toggles.children.values()] if toggles is not None and toggles.kind == "list" else []
check(sorted(names) == ["AutoRad_MustVent", "PowerToggle"], f"AutoPowerToggle toggles should be exactly PowerToggle + AutoRad_MustVent, got {names}")
for name in auto.order:
check("Ammo" not in name, f"ammo-signal component still present: {name}")
def test_manifest_is_well_formed():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
for field in ("ID", "Name", "Version", "CompatibleGameVersions", "ModifiesGameplay"):
check(field in mod, f"mod.rules is missing required field '{field}'")
mod_id = mod.get("ID")
check(
mod_id is not None and "." in mod_id.value and not mod_id.value.startswith("cosmoteer."),
"mod ID must be 'author.mod_name' and must not use the reserved 'cosmoteer' author",
)
def test_manifest_declares_existing_logo():
"""Steam Workshop shows the mod's Logo; the file must ship inside the mod
folder, be a real PNG, and stay under Steam's 500 KB preview limit."""
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
logo = mod.get("Logo")
check(logo is not None, "mod.rules is missing the 'Logo' field")
if logo is None:
return
path = os.path.join(MOD_ROOT, logo.value.strip('"'))
check(os.path.isfile(path), f"Logo file does not exist: {path}")
if not os.path.isfile(path):
return
with open(path, "rb") as f:
magic = f.read(8)
check(magic == b"\x89PNG\r\n\x1a\n", f"Logo is not a PNG: {path}")
check(os.path.getsize(path) < 500_000, "Logo must be under 500 KB for Workshop upload")
def main():
if DATA is None or not os.path.isdir(DATA):
print("SKIP: Cosmoteer Data folder not found. Set COSMOTEER_DATA to run these tests.")
return 0
print(f"Cosmoteer data: {DATA}\n")
for name, test in sorted(globals().items()):
if name.startswith("test_") and callable(test):
before = len(_failures)
test()
status = "PASS" if len(_failures) == before else "FAIL"
print(f" [{status}] {name}")
print(f"\n{_checks} checks run.")
if _failures:
print(f"{len(_failures)} FAILED:\n")
for failure in _failures:
print(f" - {failure}")
return 1
print("All checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())