diff --git a/README.md b/README.md index 28f587e..55e506d 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,12 @@ about **172 status/sec per tile** while fully overflowing, so roughly **2 seconds of continuous, total overflow** before fire risk even arms. Radiators react within **0.5 s** (`HeatDissipationConverter { Interval = 0.5 }`). -With ~2000 status heat of headroom left when venting starts, one overclocked -large reactor takes about 0.7 s to consume it, against a 0.5 s radiator reaction -time — so the overshoot lands well under the 350-per-tile threshold at which -anything begins to happen. Holding the network near full is safe with a wide -margin, and headroom given up beyond that is ammunition thrown away for nothing. +With 5% of one thermal battery (750 status heat) left when venting starts, one +overclocked large reactor takes about 0.27 s to consume it, against a 0.5 s +radiator reaction time — so the overshoot lands well under the 350-per-tile +threshold at which anything begins to happen. Holding the network near full is +safe with a wide margin, and headroom given up beyond that is ammunition thrown +away for nothing. The genuine constraint is not the threshold. It is total radiator throughput (1100/sec each) versus sustained generation. If you are short on radiators you @@ -67,10 +68,12 @@ overflow continuously and no threshold value saves you. A radiator vents when **either**: 1. **The heat network is running out of room.** The radiator measures this - itself, with a bare `NetworkResourceInput` — the same way the heat exchanger - decides whether it can still push heat (`heat_exchanger.rules`: - `IsNetworkHeatNonFull`). Vent below ~2000 status heat of remaining headroom, - hold again above ~6000, a deadband so radiators do not chatter. + itself, with a bare `NetworkResourceInput` — the same component an + overclocked part pushes its heat through (`base_part_overclock.rules`: + `NetworkHeatInput`), so it sees exactly what a producer sees. Vent while + less than **5%** of the network's heat storage is free, hold again once + **10%** is free — a deadband so radiators do not chatter. Both numbers are + named constants at the top of `components/radiator_auto.rules`. 2. **No launcher on the network is set to Thermal Canister Missiles.** If you are not spending heat as ammunition there is nothing to hoard, so radiators behave exactly as in vanilla. @@ -80,10 +83,13 @@ idle a radiator you have left switched on. The design is **fail-safe**: holding heat requires positive evidence that the network has room to spare. Anything missing, disconnected or unmeasurable ends -up venting, which is vanilla behaviour. Headroom is measured in absolute heat -rather than as a percentage, because what actually matters is headroom against -your generation rate — 2000 status heat is under a second of one overclocked -large reactor. +up venting, which is vanilla behaviour. + +On one thermal battery (15000 status heat) 5% free is 750 of headroom. An +overclocked large reactor fills that in ~0.27 s against the radiator's 0.5 s +interval, so the worst overshoot is ~45 heat per tile, an order of magnitude +under the 350 where anything starts to happen. More storage means more +absolute headroom at the same percentage. No stat or number is changed. Only *when* radiators run. @@ -95,6 +101,7 @@ components/radiator_auto.rules measures network headroom, decides, acts components/launcher_ammo_signal.rules reports thermal ammo in use tests/ offline validation suite tools/dotnet_enum.py reads component schemas out of Cosmoteer.dll +tools/ildump.py disassembles component methods to readable CIL install.sh validate + copy into the Mods folder ``` @@ -139,32 +146,50 @@ holding heat back for your launchers. ## Status -**Partly confirmed.** Against game data 0.30.4c, as of 2026-08-29: +**Unconfirmed.** Against game data 0.30.4c, as of 2026-08-29: -- Confirmed: the gate closes and idles a radiator when thermal ammo is in use. -- Confirmed: with no launcher on thermal ammo, behaviour is vanilla. -- **Not yet confirmed: the release path.** v2.0.0 held heat *forever* — thermal - batteries visibly full, parts taking heat damage, radiators still idle. +- Confirmed in game: the gate closes and idles a radiator when thermal ammo is + in use, and with no launcher on thermal ammo behaviour is vanilla. +- **Not yet confirmed: the release path** — that radiators start venting as the + network fills. v3.0.0 is the first build whose threshold is on the right + scale, and it has not been played yet. -That failure was the dangerous direction, and it came from reading the wrong -thing. v2.0.0 put a percentage threshold on a thermal battery's own -`HeatStorage`, which is wrapped by a `NetworkResourceStore`; that local read -never crossed its ceiling however full the network got. v2.1.0 measures the -network's remaining room directly instead, from the radiator, using the same -bare `NetworkResourceInput` the heat exchanger uses — and the polarity is now -fail-safe, so an unmeasurable signal vents rather than holds. +### What the assembly says -Getting this far took five builds that each loaded without a single error and -misbehaved silently. Cosmoteer reports none of these, so each has a -mutation-verified test: +Nothing about these components is documented, and guessing cost six builds. +`tools/ildump.py` disassembles `Cosmoteer.dll`, and three methods settle how +the measurement actually works: + +- `PartThresholdToggle.OnAmountChanged`: `IsToggleOn = (Value >= MinAmount && + Value <= MaxAmount) != Invert`. Plain floats. **There is no percent logic** — + `88%` in a `.rules` file is simply 0.88. +- `BaseResourceStorage.get_Value`: `Lerp(ProvidedValueRange, Resources / + MaxResources) + Resources × ProvidedValuePerResource`. The default range is + `[0, 1]`, so a storage's value is its **fill fraction**. That is why vanilla + can write `MaxAmount = 88%` against a network-wrapped `HeatStorage` + (`resonance_beam_turret.rules`). +- `PartNetworkResourceInput.get_Value`: `Lerp(ProvidedValueRange, 1 − + Resources / MaxResources)`, where `Resources` is the connected sinks' used + capacity and `MaxResources` their total. So its value is the **free fraction + of the network**, and 0 when nothing is attached. + +v2.1.0 compared that 0..1 free fraction against `2000000`. "Space low" was +therefore permanently true, radiators always vented, and the mod was a no-op +with a clean load. v2.0.0's failure was elsewhere, in a battery-to-radiator +broadcast chain that v2.1.0 had already removed. + +### Silent failures and their guards + +Every build so far loaded without a single error. Cosmoteer reports none of +these, so each has a mutation-verified test: | Silent failure | Guard | |---|---| | `NetworkToggle` / `NetworkToggleReceiver` carry no signal on a physical ship (every vanilla use sets `IncludeInPhysical = false`) | `test_no_blueprint_only_signal_types`, plus `test_vanilla_marks_those_types_blueprint_only` so the premise cannot go stale | -| A field name that does not exist in the component's real schema is ignored rather than reported | `test_component_fields_exist_in_schema`, read from the assembly's own metadata | +| A field name that does not exist in the component's real schema is ignored rather than reported | `test_component_fields_exist_in_schema`, read from the assembly's own metadata (ALL_CAPS local constants exempt, the vanilla convention) | | A network component no route reaches never attaches, and reads as absent forever | `test_network_components_are_routed` | | An `Overrides` value naming a component that is not there reverts the gate to nothing | `test_override_values_name_real_components` | -| A percentage threshold on a network-wrapped `ResourceStorage` never crosses, however full the network is | no test — avoid the pattern; measure the network, not a part's storage | +| A threshold on a fraction-valued source compared against an absolute number is permanently on or off | `test_threshold_amounts_match_value_scale` | Routing was the cause of the first three builds doing nothing. A network component is only attached if a **route** reaches it: the part's `NetworkPort` @@ -173,18 +198,25 @@ other network component on the part. That is how vanilla connects the radiator's own `NetworkedThermalDissipationLoadValue`, and how the amplification pump reaches the resonance beam turret. Many parts ship no router at all. -None of this is documented: the wiki's modding pages cover no network components -at all, so the game's own `.rules` files are the only reference. +### Tools `tools/dotnet_enum.py` reads component schemas and enum members straight out of -`Cosmoteer.dll`, which is how the field and type questions got settled. It is -useful on its own for any Cosmoteer modding: +`Cosmoteer.dll` with no dependencies: ```sh python3 tools/dotnet_enum.py /path/to/Cosmoteer.dll PartThresholdToggleRules python3 tools/dotnet_enum.py /path/to/Cosmoteer.dll MultiToggleMode ``` +`tools/ildump.py` goes further and disassembles methods, resolving calls, +fields and strings to names. It needs `dnfile` and `dncil` (pure Python; set +`ILDUMP_PKGS` to a directory of unpacked wheels if they are not installed): + +```sh +python3 tools/ildump.py /path/to/Cosmoteer.dll PartNetworkResourceInput get_Value +python3 tools/ildump.py /path/to/Cosmoteer.dll BaseResourceStorageRules .ctor +``` + Version 1.4.0 in the git history carries a probe build that makes a radiator report its own internal state through indicator icons, if this ever needs debugging again. @@ -193,4 +225,4 @@ debugging again. - A per-radiator UI toggle for auto/manual, which needs a new `ChoiceID` wired into `Data/gui/game/parts/part_toggles.rules`. -- Making the 98% / 94% band configurable. +- Exposing the 5% / 10% band in the game UI rather than as file constants. diff --git a/components/radiator_auto.rules b/components/radiator_auto.rules index 17dfd21..10a8189 100644 --- a/components/radiator_auto.rules +++ b/components/radiator_auto.rules @@ -18,41 +18,41 @@ // --- Is the heat network running out of room? ------------------------------- // -// Measured locally, exactly the way the heat exchanger decides whether it can -// still push heat into the network (heat_exchanger.rules: IsNetworkHeatNonFull -// reads a bare NetworkResourceInput). A NetworkResourceInput's value is how much -// the network can still accept. +// Measured locally with a bare NetworkResourceInput, the same component an +// overclocked part pushes its heat through (base_part_overclock.rules: +// NetworkHeatInput), so this is exactly the producer's view of the network. // -// Reading a thermal battery's own HeatStorage does NOT work for this. That -// storage is wrapped by a NetworkResourceStore, and v2.0.0 held heat forever -- -// batteries visibly full, parts cooking -- because a local percentage threshold -// on it never crossed. This measures the network itself instead, from the part -// that acts on the answer, so no cross-part broadcast is involved at all. -// -// Amounts are resource units: 1 status heat = 1000 resource units -// (statuses/heat/heat.rules: STATUS_TO_RESOURCE_RATIO). +// Its Value, from Cosmoteer.dll (PartNetworkResourceInput.get_Value, see +// tools/ildump.py), is the FREE FRACTION of the connected heat storage: +// 1 - stored / total capacity -> 1.0 empty, 0.0 full, 0 if nothing attached +// and a ThresholdToggle compares that against MinAmount/MaxAmount as plain +// floats, so the amounts below are fractions too ("5%" is 0.05). AutoRad_NetworkSpace { Type = NetworkResourceInput ResourceType = heat Mode = DistributeProportionallyByAvailable + Categories = [ thermal_storage ] } -// Under ~2000 status heat of headroom left: vent. +// Vent while less than VENT_BELOW_FREE of the network is still free. AutoRad_SpaceLow { Type = ThresholdToggle ValueFrom = AutoRad_NetworkSpace - MaxAmount = 2000000 + VENT_BELOW_FREE = 5% + MaxAmount = &VENT_BELOW_FREE } -// Back above ~6000 status heat of headroom: safe to bank heat again. +// Hold heat again once at least HOLD_ABOVE_FREE is free. The gap between the +// two is a deadband so radiators do not chatter at the threshold. AutoRad_SpaceAmple { Type = ThresholdToggle ValueFrom = AutoRad_NetworkSpace - MinAmount = 6000000 + HOLD_ABOVE_FREE = 10% + MinAmount = &HOLD_ABOVE_FREE } AutoRad_LowTrigger diff --git a/mod.rules b/mod.rules index 3a705d4..c1c0e35 100644 --- a/mod.rules +++ b/mod.rules @@ -21,7 +21,7 @@ ID = ganter.auto_radiator Name = "Auto Radiator" -Version = 2.1.0 +Version = 3.0.0 CompatibleGameVersions = ["0.30.4c", "0.30.4"] ModifiesGameplay = true Author = "Tobias Ganter" diff --git a/tests/test_mod.py b/tests/test_mod.py index 0c983a2..559f043 100644 --- a/tests/test_mod.py +++ b/tests/test_mod.py @@ -425,6 +425,10 @@ def test_component_fields_exist_in_schema(): for field in component.order: if field == "Type": continue + # ALL_CAPS entries are local constants for &references, the + # vanilla convention (thermal_battery.rules: MAX_STATUS_HEAT). + if field.isupper(): + continue check( field in schema, f"{name}: {component_name} ({type_node.value}) has no field " @@ -627,6 +631,73 @@ def test_added_indicators_are_valid(): # 8. The mod manifest must declare the fields Cosmoteer requires. # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Threshold amounts must live on the same scale as the value they read. +# +# From Cosmoteer.dll (see tools/ildump.py): PartThresholdToggle compares +# ValueFrom.Value against MinAmount/MaxAmount as plain floats -- "5%" is 0.05, +# nothing more. A ResourceStorage, NetworkResourceStore or NetworkResourceInput +# with the default ProvidedValueRange reports a 0..1 fraction (fill for +# storages, free space for inputs). v2.1.0 compared such a fraction against +# 2000000, so its "space low" toggle was permanently on and the mod was a no-op. +# --------------------------------------------------------------------------- + +FRACTION_VALUED_TYPES = {"ResourceStorage", "NetworkResourceStore", "NetworkResourceInput"} + + +def scalar_number(node, scope=None): + """Turn a scalar like '5%', '0.25' or '&LOCAL_CONSTANT' into a float. + + A leading '&NAME' is looked up in the given scope (the component itself), + the vanilla convention for local constants. Returns None if not numeric. + """ + if node is None: + return None + text = node.value.strip() + if text.startswith("&") and scope is not None: + return scalar_number(scope.get(text[1:].strip("()")), None) + try: + if text.endswith("%"): + return float(text[:-1]) / 100.0 + return float(text) + except ValueError: + return None + + +def test_threshold_amounts_match_value_scale(): + check(scalar_number(rules_parser.Node("scalar", "5%")) == 0.05, "'5%' should read as 0.05") + scope = rules_parser.parse("K = 40%\nX = &K") + check(scalar_number(scope.get("X"), scope) == 0.4, "'&K' should resolve through the scope") + for mod_file, target in added_component_sets(): + added = rules_parser.parse_file(mod_file) + for name in added.order: + comp = added.get(name) + if comp.kind != "group" or comp.get("Type") is None: + continue + if comp.get("Type").value != "ThresholdToggle": + continue + source_name = comp.get("ValueFrom").value if comp.get("ValueFrom") else None + source = added.get(source_name) if source_name else None + if source is None and target is not None: + source = target.get(source_name) + if source is None or source.get("Type") is None: + continue + source_type = source.get("Type").value + if source_type not in FRACTION_VALUED_TYPES: + continue + if source.get("ProvidedValueRange") is not None or source.get("ProvidedValuePerResource") is not None: + continue + for field in ("MinAmount", "MaxAmount"): + amount = scalar_number(comp.get(field), comp) + if amount is None: + continue + check( + 0.0 <= amount <= 1.0, + f"{os.path.basename(mod_file)}: {name}.{field} = {amount} but " + f"{source_name} ({source_type}) reports a 0..1 fraction", + ) + + 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"): diff --git a/tools/ildump.py b/tools/ildump.py new file mode 100755 index 0000000..bdfe567 --- /dev/null +++ b/tools/ildump.py @@ -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 [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 [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))