diff --git a/debug_mode.sh b/debug_mode.sh deleted file mode 100755 index 46bdd51..0000000 --- a/debug_mode.sh +++ /dev/null @@ -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" diff --git a/tests/test_mod.py b/tests/test_mod.py index 2d3ba7f..4c2de67 100644 --- a/tests/test_mod.py +++ b/tests/test_mod.py @@ -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. # --------------------------------------------------------------------------- diff --git a/tools/dotnet_enum.py b/tools/dotnet_enum.py new file mode 100644 index 0000000..d54218c --- /dev/null +++ b/tools/dotnet_enum.py @@ -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 [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("= (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())