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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user