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:
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ABOUT ME
|
||||
# Turns Cosmoteer's built-in DebugMode on or off by editing settings.rules.
|
||||
#
|
||||
# Why it exists: the game binary contains a part debugger (OpenPartDebugger,
|
||||
# PartDebugValue / PartDebugToggle / PartDebugTrigger) which can show a part's
|
||||
# live component values. That is the only way to see, from inside the game, why
|
||||
# a modded toggle chain is not doing what it should. It is gated behind the
|
||||
# DebugMode setting, which has no in-game UI in a release build.
|
||||
#
|
||||
# Cosmoteer rewrites settings.rules when it exits, so the game must be closed
|
||||
# before running this or the change is lost.
|
||||
#
|
||||
# Usage: ./debug_mode.sh on
|
||||
# ./debug_mode.sh off
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:-}"
|
||||
case "$MODE" in
|
||||
on) VALUE="Normal" ;;
|
||||
off) VALUE="None" ;;
|
||||
*) echo "Usage: $0 on|off" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
find_settings() {
|
||||
local candidates=(
|
||||
"$HOME"/.local/share/Steam/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/"Saved Games"/Cosmoteer/*/settings.rules
|
||||
/mnt/*/SteamLibrary/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/"Saved Games"/Cosmoteer/*/settings.rules
|
||||
"$HOME"/"Saved Games"/Cosmoteer/*/settings.rules
|
||||
"$HOME"/Documents/"My Games"/Cosmoteer/*/settings.rules
|
||||
)
|
||||
for path in "${candidates[@]}"; do
|
||||
[ -f "$path" ] && printf '%s\n' "$path" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
SETTINGS="${COSMOTEER_SETTINGS:-$(find_settings || true)}"
|
||||
|
||||
if [ -z "$SETTINGS" ]; then
|
||||
echo "Could not find settings.rules. Set COSMOTEER_SETTINGS and run again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if pgrep -f -i cosmoteer >/dev/null 2>&1; then
|
||||
echo "Cosmoteer is running. Quit it first -- it rewrites settings.rules on exit" >&2
|
||||
echo "and would discard this change." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -f "$SETTINGS.bak-preautorad" ] || cp "$SETTINGS" "$SETTINGS.bak-preautorad"
|
||||
|
||||
if grep -qE '^DebugMode = ' "$SETTINGS"; then
|
||||
sed -i "s/^DebugMode = .*/DebugMode = $VALUE/" "$SETTINGS"
|
||||
else
|
||||
printf 'DebugMode = %s\n' "$VALUE" >> "$SETTINGS"
|
||||
fi
|
||||
|
||||
echo "DebugMode = $VALUE"
|
||||
echo "Backup at $SETTINGS.bak-preautorad"
|
||||
@@ -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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# ABOUT ME
|
||||
# Reads the member names of an enum out of a .NET assembly, without needing
|
||||
# ILSpy, monodis or any other toolchain.
|
||||
#
|
||||
# Why it exists: Cosmoteer's DebugMode setting is an enum with no in-game UI and
|
||||
# no documentation. Guessing a value crashes the game on startup with
|
||||
# "Deserialization from ... /DebugMode failed" at Enum.Parse, which costs a full
|
||||
# relaunch per guess. This walks the PE file's CLI metadata tables directly and
|
||||
# prints the real members, so the value is known before the game is started.
|
||||
#
|
||||
# It parses only as far as it must: PE headers -> CLI header -> metadata root ->
|
||||
# the #~ table stream -> the Module, TypeRef, TypeDef and Field tables.
|
||||
#
|
||||
# Usage: python3 tools/dotnet_enum.py <assembly.dll> [EnumName]
|
||||
# With no EnumName, lists every type whose name contains "Debug".
|
||||
|
||||
import struct
|
||||
import sys
|
||||
|
||||
# Table indices used here.
|
||||
MODULE, TYPEREF, TYPEDEF, FIELDPTR, FIELD = 0x00, 0x01, 0x02, 0x03, 0x04
|
||||
METHODDEF = 0x06
|
||||
MODULEREF, TYPESPEC, ASSEMBLYREF = 0x1A, 0x1B, 0x23
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, data, pos=0):
|
||||
self.data = data
|
||||
self.pos = pos
|
||||
|
||||
def u16(self):
|
||||
value = struct.unpack_from("<H", self.data, self.pos)[0]
|
||||
self.pos += 2
|
||||
return value
|
||||
|
||||
def u32(self):
|
||||
value = struct.unpack_from("<I", self.data, self.pos)[0]
|
||||
self.pos += 4
|
||||
return value
|
||||
|
||||
def u64(self):
|
||||
value = struct.unpack_from("<Q", self.data, self.pos)[0]
|
||||
self.pos += 8
|
||||
return value
|
||||
|
||||
|
||||
def find_cli_metadata(data):
|
||||
"""Return (metadata_bytes_offset) for the CLI metadata root."""
|
||||
pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
if data[pe_offset : pe_offset + 4] != b"PE\0\0":
|
||||
raise ValueError("not a PE file")
|
||||
|
||||
coff = pe_offset + 4
|
||||
number_of_sections = struct.unpack_from("<H", data, coff + 2)[0]
|
||||
size_of_optional = struct.unpack_from("<H", data, coff + 16)[0]
|
||||
optional = coff + 20
|
||||
magic = struct.unpack_from("<H", data, optional)[0]
|
||||
# PE32 (0x10b) has a 28-byte windows-specific prefix difference vs PE32+.
|
||||
data_dir = optional + (96 if magic == 0x10B else 112)
|
||||
|
||||
sections = []
|
||||
section_table = optional + size_of_optional
|
||||
for i in range(number_of_sections):
|
||||
base = section_table + i * 40
|
||||
virtual_size, virtual_address, raw_size, raw_ptr = struct.unpack_from(
|
||||
"<IIII", data, base + 8
|
||||
)
|
||||
sections.append((virtual_address, max(virtual_size, raw_size), raw_ptr))
|
||||
|
||||
def rva_to_offset(rva):
|
||||
for virtual_address, size, raw_ptr in sections:
|
||||
if virtual_address <= rva < virtual_address + size:
|
||||
return raw_ptr + (rva - virtual_address)
|
||||
raise ValueError(f"RVA {rva:#x} is not in any section")
|
||||
|
||||
# Data directory entry 14 is the CLI header.
|
||||
cli_rva = struct.unpack_from("<I", data, data_dir + 14 * 8)[0]
|
||||
cli_offset = rva_to_offset(cli_rva)
|
||||
metadata_rva = struct.unpack_from("<I", data, cli_offset + 8)[0]
|
||||
return rva_to_offset(metadata_rva)
|
||||
|
||||
|
||||
def parse_streams(data, root):
|
||||
if data[root : root + 4] != b"BSJB":
|
||||
raise ValueError("CLI metadata signature not found")
|
||||
version_length = struct.unpack_from("<I", data, root + 12)[0]
|
||||
pos = root + 16 + version_length
|
||||
pos += 2 # flags
|
||||
stream_count = struct.unpack_from("<H", data, pos)[0]
|
||||
pos += 2
|
||||
|
||||
streams = {}
|
||||
for _ in range(stream_count):
|
||||
offset, size = struct.unpack_from("<II", data, pos)
|
||||
pos += 8
|
||||
end = data.index(b"\0", pos)
|
||||
name = data[pos:end].decode("ascii")
|
||||
pos = end + 1
|
||||
pos = (pos + 3) & ~3 # names are padded to a 4-byte boundary
|
||||
streams[name] = (root + offset, size)
|
||||
return streams
|
||||
|
||||
|
||||
class Tables:
|
||||
def __init__(self, data, streams):
|
||||
self.data = data
|
||||
self.strings_base = streams["#Strings"][0]
|
||||
|
||||
base, _size = streams.get("#~") or streams["#-"]
|
||||
reader = Reader(data, base)
|
||||
reader.u32() # reserved
|
||||
reader.u16() # major/minor version
|
||||
heap_sizes = data[reader.pos]
|
||||
reader.pos += 2 # heap sizes + reserved
|
||||
valid = reader.u64()
|
||||
reader.u64() # sorted
|
||||
|
||||
self.string_index_size = 4 if heap_sizes & 0x01 else 2
|
||||
self.guid_index_size = 4 if heap_sizes & 0x02 else 2
|
||||
self.blob_index_size = 4 if heap_sizes & 0x04 else 2
|
||||
|
||||
self.rows = {}
|
||||
for table in range(64):
|
||||
if valid & (1 << table):
|
||||
self.rows[table] = reader.u32()
|
||||
|
||||
self.row_start = reader.pos
|
||||
|
||||
def count(self, table):
|
||||
return self.rows.get(table, 0)
|
||||
|
||||
def simple_index(self, table):
|
||||
return 4 if self.count(table) >= (1 << 16) else 2
|
||||
|
||||
def coded_index(self, tables, tag_bits):
|
||||
largest = max((self.count(t) for t in tables), default=0)
|
||||
return 4 if largest >= (1 << (16 - tag_bits)) else 2
|
||||
|
||||
def row_size(self, table):
|
||||
s, g, b = self.string_index_size, self.guid_index_size, self.blob_index_size
|
||||
if table == MODULE:
|
||||
return 2 + s + 3 * g
|
||||
if table == TYPEREF:
|
||||
return self.coded_index([MODULE, MODULEREF, ASSEMBLYREF, TYPEREF], 2) + 2 * s
|
||||
if table == TYPEDEF:
|
||||
return (
|
||||
4
|
||||
+ 2 * s
|
||||
+ self.coded_index([TYPEDEF, TYPEREF, TYPESPEC], 2)
|
||||
+ self.simple_index(FIELD)
|
||||
+ self.simple_index(METHODDEF)
|
||||
)
|
||||
if table == FIELDPTR:
|
||||
return self.simple_index(FIELD)
|
||||
if table == FIELD:
|
||||
return 2 + s + b
|
||||
raise ValueError(f"row size for table {table:#x} not implemented")
|
||||
|
||||
def table_offset(self, table):
|
||||
offset = self.row_start
|
||||
for earlier in sorted(self.rows):
|
||||
if earlier == table:
|
||||
return offset
|
||||
offset += self.row_size(earlier) * self.rows[earlier]
|
||||
raise ValueError(f"table {table:#x} not present")
|
||||
|
||||
def string(self, index):
|
||||
end = self.data.index(b"\0", self.strings_base + index)
|
||||
return self.data[self.strings_base + index : end].decode("utf-8", "replace")
|
||||
|
||||
def read_index(self, reader, size):
|
||||
return reader.u32() if size == 4 else reader.u16()
|
||||
|
||||
def typedefs(self):
|
||||
"""Yield (name, first_field_index) for every TypeDef, 1-based fields."""
|
||||
for i, name, first_field, _extends in self.typedefs_full():
|
||||
yield i, name, first_field
|
||||
|
||||
def typedefs_full(self):
|
||||
"""As typedefs(), plus the row index of the base type, or None.
|
||||
|
||||
Extends is a TypeDefOrRef coded index: the low 2 bits are the tag, and
|
||||
tag 0 means TypeDef, which is the only case that can be followed within
|
||||
this assembly. Base types in other assemblies are reported as None.
|
||||
"""
|
||||
base = self.table_offset(TYPEDEF)
|
||||
size = self.row_size(TYPEDEF)
|
||||
extends_size = self.coded_index([TYPEDEF, TYPEREF, TYPESPEC], 2)
|
||||
field_size = self.simple_index(FIELD)
|
||||
for i in range(self.count(TYPEDEF)):
|
||||
reader = Reader(self.data, base + i * size)
|
||||
reader.u32() # flags
|
||||
name = self.string(self.read_index(reader, self.string_index_size))
|
||||
self.read_index(reader, self.string_index_size) # namespace
|
||||
extends = self.read_index(reader, extends_size)
|
||||
first_field = self.read_index(reader, field_size)
|
||||
base_row = None
|
||||
if extends and (extends & 0x3) == 0:
|
||||
base_row = (extends >> 2) - 1
|
||||
yield i, name, first_field, base_row
|
||||
|
||||
def field_name(self, one_based_index):
|
||||
base = self.table_offset(FIELD)
|
||||
size = self.row_size(FIELD)
|
||||
reader = Reader(self.data, base + (one_based_index - 1) * size)
|
||||
reader.u16() # flags
|
||||
return self.string(self.read_index(reader, self.string_index_size))
|
||||
|
||||
|
||||
def clean(field_name):
|
||||
"""Turn a compiler backing-field name into the property name."""
|
||||
if field_name.startswith("<") and field_name.endswith(">k__BackingField"):
|
||||
return field_name[1:-len(">k__BackingField")]
|
||||
return field_name
|
||||
|
||||
|
||||
def load(assembly_path):
|
||||
with open(assembly_path, "rb") as handle:
|
||||
data = handle.read()
|
||||
return Tables(data, parse_streams(data, find_cli_metadata(data)))
|
||||
|
||||
|
||||
def schema(tables, type_name, follow_bases=True):
|
||||
"""Return the set of field names declared by a type, including inherited."""
|
||||
rows = list(tables.typedefs_full())
|
||||
by_name = {name: position for position, (_i, name, _f, _b) in enumerate(rows)}
|
||||
field_count = tables.count(FIELD)
|
||||
|
||||
def declared(position):
|
||||
_i, _name, first_field, _base = rows[position]
|
||||
last_field = rows[position + 1][2] - 1 if position + 1 < len(rows) else field_count
|
||||
return {
|
||||
clean(tables.field_name(index))
|
||||
for index in range(first_field, last_field + 1)
|
||||
if index >= 1
|
||||
}
|
||||
|
||||
position = by_name.get(type_name)
|
||||
if position is None:
|
||||
return None
|
||||
|
||||
names = set()
|
||||
seen = set()
|
||||
while position is not None and position not in seen:
|
||||
seen.add(position)
|
||||
names |= declared(position)
|
||||
position = rows[position][3] if follow_bases else None
|
||||
names.discard("value__")
|
||||
return names
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
return 2
|
||||
|
||||
with open(sys.argv[1], "rb") as handle:
|
||||
data = handle.read()
|
||||
|
||||
tables = Tables(data, parse_streams(data, find_cli_metadata(data)))
|
||||
typedefs = list(tables.typedefs())
|
||||
field_count = tables.count(FIELD)
|
||||
|
||||
wanted = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
for position, (_index, name, first_field) in enumerate(typedefs):
|
||||
if wanted is None:
|
||||
if "Debug" in name:
|
||||
print(name)
|
||||
continue
|
||||
if name != wanted:
|
||||
continue
|
||||
|
||||
# A type's fields run until the next type's first field.
|
||||
if position + 1 < len(typedefs):
|
||||
last_field = typedefs[position + 1][2] - 1
|
||||
else:
|
||||
last_field = field_count
|
||||
|
||||
print(f"{name}:")
|
||||
for field_index in range(first_field, last_field + 1):
|
||||
field = tables.field_name(field_index)
|
||||
if field == "value__":
|
||||
continue # the enum's synthetic backing field
|
||||
print(f" {field}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user