test: validate component fields against the game's own metadata

Drop debug_mode.sh. Setting DebugMode = Normal crashed the game on
startup (Enum.Parse on a member that does not exist), and there is in
fact no DebugMode type in the assembly at all, so that route was never
going to work. Developer mode is launched directly instead.

Investigating it produced something worth keeping: a reader for the
assembly's CLI metadata tables. It walks PE headers to the #~ stream and
resolves a type's fields including inherited ones, which gives the real
rules schema of any component.

Wire that into the tests. Cosmoteer ignores unrecognised fields rather
than reporting them, so a misspelled field name yields a mod that loads
cleanly and silently does nothing -- the exact failure mode this project
has been stuck in. Nothing else catches it.

The check confirms the current components are all well-formed, which
narrows the remaining fault to runtime behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni
This commit is contained in:
2026-08-28 23:59:42 +02:00
co-authored by Claude Opus 5
parent 8688c8552f
commit a4f25f1710
3 changed files with 358 additions and 61 deletions
+68
View File
@@ -364,6 +364,74 @@ def test_no_name_collisions_with_vanilla():
)
# ---------------------------------------------------------------------------
# 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",
)
# ---------------------------------------------------------------------------
# 7. Added indicators must bind to a real toggle and a real atlas sprite.
# ---------------------------------------------------------------------------