fix: v3.0.0 -- thresholds on the free-fraction scale the assembly actually uses

Disassembling Cosmoteer.dll (tools/ildump.py) shows PartThresholdToggle
compares plain floats with no percent logic, and that a
NetworkResourceInput's Value is the free fraction (0..1) of the connected
network. v2.1.0 compared that against 2000000, so it always vented.

Thresholds are now 5% / 10% free as local constants. New guard
test_threshold_amounts_match_value_scale fails on the old values. README
corrects the earlier wrong diagnosis of v2.0.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni
This commit is contained in:
2026-08-29 01:23:02 +02:00
co-authored by Claude Fable 5
parent da331e955c
commit c5ddc78495
5 changed files with 294 additions and 52 deletions
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
# ABOUT ME
# Disassembles methods of named classes in Cosmoteer.dll to readable CIL, with
# call/field/string operands resolved to names. Exists because the wiki
# documents none of the component internals: this is how the mod learned that
# PartThresholdToggle compares plain floats (no percent logic) and that a
# NetworkResourceInput's Value is the free fraction of the connected network.
#
# Usage:
# python3 tools/ildump.py <Cosmoteer.dll> <Type[,Type...]> [method-substring[,...]]
#
# python3 tools/ildump.py Bin/Cosmoteer.dll PartThresholdToggle OnAmountChanged
# python3 tools/ildump.py Bin/Cosmoteer.dll PartNetworkResourceInput get_Value
# python3 tools/ildump.py Bin/Cosmoteer.dll BaseResourceStorageRules .ctor # field defaults
#
# Needs the pure-Python packages dnfile (0.18.0) and dncil (1.0.2), plus pefile.
# Set ILDUMP_PKGS to a directory holding unpacked wheels if they are not
# installed system-wide. tools/dotnet_enum.py answers the cheaper question
# "which fields does this Rules class have" without any dependency.
import os
import sys
if os.environ.get("ILDUMP_PKGS"):
sys.path.insert(0, os.environ["ILDUMP_PKGS"])
import dnfile # noqa: E402
from dncil.cil.body import CilMethodBody # noqa: E402
from dncil.cil.body.reader import CilMethodBodyReaderBase # noqa: E402
TABLES = {
0x01: "TypeRef",
0x02: "TypeDef",
0x04: "Field",
0x06: "MethodDef",
0x0A: "MemberRef",
0x1B: "TypeSpec",
0x2B: "MethodSpec",
}
class _Reader(CilMethodBodyReaderBase):
def __init__(self, pe, row):
self.pe = pe
self.off = pe.get_offset_from_rva(row.Rva)
def read(self, n):
data = self.pe.get_data(self.pe.get_rva_from_offset(self.off), n)
self.off += n
return data
def tell(self):
return self.off
def seek(self, offset):
self.off = offset
return offset
class Dumper:
def __init__(self, dll_path):
self.pe = dnfile.dnPE(dll_path)
self.method_owner = {}
for tdef in self.pe.net.mdtables.TypeDef.rows:
for method in tdef.MethodList:
self.method_owner[id(method.row)] = str(tdef.TypeName)
def resolve(self, token):
value = token.value if hasattr(token, "value") else int(token)
table, rid = value >> 24, value & 0xFFFFFF
if table == 0x70:
try:
return repr(self.pe.net.user_strings.get(rid).value)
except Exception:
return f"str#{rid}"
name = TABLES.get(table)
if name is None:
return f"tok{value:08x}"
try:
row = getattr(self.pe.net.mdtables, name).rows[rid - 1]
except Exception:
return f"{name}#{rid}"
if name == "MethodDef":
return f"{self.method_owner.get(id(row), '?')}::{row.Name}"
if name == "MemberRef":
return f"{getattr(row.Class.row, 'TypeName', '?')}::{row.Name}"
if name == "Field":
return f"fld {row.Name}"
if name in ("TypeDef", "TypeRef"):
return f"type {row.TypeName}"
return name
def find_type(self, type_name):
for tdef in self.pe.net.mdtables.TypeDef.rows:
if str(tdef.TypeName) == type_name:
return tdef
return None
def dump(self, type_name, only=None, out=sys.stdout):
tdef = self.find_type(type_name)
if tdef is None:
print(f"no type {type_name}", file=out)
return
parent = getattr(tdef.Extends.row, "TypeName", "?")
print(f"=== {type_name} extends {parent}", file=out)
print("fields:", [str(f.row.Name) for f in tdef.FieldList], file=out)
for method in (m.row for m in tdef.MethodList):
name = str(method.Name)
if only and not any(o in name for o in only):
continue
print(f"--- {name}", file=out)
if not method.Rva:
continue
try:
body = CilMethodBody(_Reader(self.pe, method))
except Exception as error:
print(f" (no body: {error})", file=out)
continue
for ins in body.instructions:
operand = ins.operand
if type(operand).__name__.endswith("Token"):
operand = self.resolve(operand)
shown = "" if operand is None else operand
print(f" {ins.offset:04x} {ins.opcode.name} {shown}", file=out)
def main(argv):
if len(argv) < 3:
print(__doc__ or "usage: ildump.py <Cosmoteer.dll> <Type[,Type]> [method[,method]]")
return 2
dumper = Dumper(argv[1])
only = argv[3].split(",") if len(argv) > 3 else None
for type_name in argv[2].split(","):
dumper.dump(type_name, only)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))