# 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 automatically, or set COSMOTEER_DATA to override. import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import rules_parser MOD_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CANDIDATE_DATA_DIRS = [ "/mnt/SSD4TB/SteamLibrary/steamapps/common/Cosmoteer/Data", os.path.expanduser("~/.local/share/Steam/steamapps/common/Cosmoteer/Data"), os.path.expanduser("~/.steam/steam/steamapps/common/Cosmoteer/Data"), ] # 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(): override = os.environ.get("COSMOTEER_DATA") if override: return override for path in CANDIDATE_DATA_DIRS: if os.path.isdir(path): return path return None 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 '/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 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("/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. # --------------------------------------------------------------------------- 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 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())