Workshop shows Author and ID; keep the real name off it. ID change is free only before the first upload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014TkQMhvEyoFqKzeCC69N6o
272 lines
12 KiB
Markdown
272 lines
12 KiB
Markdown
# Auto Radiator
|
||
|
||
A Cosmoteer mod. Radiators stop dumping heat the ship still wants.
|
||
|
||
## The problem
|
||
|
||
Thermal Canister Missiles are built from the ship's own heat. The game says so
|
||
itself, in `Data/strings/en.rules`:
|
||
|
||
> Manufactures guided Thermal Canister Missiles that take thermal energy from
|
||
> their own ship's heat pipe network and inflict it upon the target.
|
||
|
||
The launcher charges each missile from the heat network — `HEAT_PER_MISSILE =
|
||
8500`, two missiles buffered per launcher. A radiator pulls from that same
|
||
network, through a component that is behaviourally identical to the launcher's:
|
||
|
||
| | Radiator | Thermal launcher |
|
||
|---|---|---|
|
||
| Type | `NetworkResourceOutput` | `NetworkResourceOutput` |
|
||
| Mode | `DistributeProportionallyByAvailable` | `DistributeProportionallyByAvailable` |
|
||
| Categories | `[ thermal_storage ]` | `[ thermal_storage ]` |
|
||
|
||
`Data/ships/priorities.rules` defines priorities for crew and supply only —
|
||
nothing for heat. So the two compete flat, proportionally, and a radiator
|
||
running constantly is spending your ammunition to make steam.
|
||
|
||
## Why holding heat is safe
|
||
|
||
Heat storage being full does not hurt you. Damage comes only from *overflow*.
|
||
Every overclocked part routes its heat through this
|
||
(`Data/ships/base_part_overclock.rules`):
|
||
|
||
```
|
||
HeatDistributionStorage
|
||
{
|
||
Type = MultiResourceStorage
|
||
ResourceStorages = [NetworkHeatInput, OverflowHeatStorage]
|
||
AddMode = InOrder
|
||
}
|
||
```
|
||
|
||
`InOrder` means the network is tried first, and `OverflowHeatStorage` only
|
||
receives what the network refuses. Overflow then applies the `cosmoteer.heat`
|
||
status to the producing part's own tiles.
|
||
|
||
From `Data/statuses/heat/heat.rules`, that status does nothing whatsoever below
|
||
**350** per tile (`RequireValueRangeForTick = [350, Infinity]`). Fire chance
|
||
ramps from 350 to 2500; thermal damage begins at 700.
|
||
|
||
An overclocked large reactor produces 2750 heat/sec over its 4×4 footprint —
|
||
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 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
|
||
overflow continuously and no threshold value saves you.
|
||
|
||
## What the mod does
|
||
|
||
The radiator's own on/off switch picks a mode:
|
||
|
||
- **ON** — vanilla. The radiator vents constantly. You want the heat gone.
|
||
- **OFF** — bank heat. The radiator idles while the heat network has room and
|
||
vents only when it is nearly full: 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). The ship never overflows heat onto its own
|
||
parts, and thermal batteries stay topped up as a deep missile reserve.
|
||
|
||
The radiator measures the network 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. Both thresholds are named constants in
|
||
`components/radiator_auto.rules`.
|
||
|
||
The design is **fail-safe**: idling requires positive evidence that the
|
||
network has room to spare. Anything missing, disconnected or unmeasurable ends
|
||
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.
|
||
|
||
## Layout
|
||
|
||
```
|
||
mod.rules manifest + patch actions
|
||
logo.png Workshop preview / in-game mod logo
|
||
components/radiator_auto.rules measures network headroom, gates the radiator
|
||
tests/ offline validation suite
|
||
tools/steam_paths.py finds the game and the Mods folder via Steam's library list
|
||
tools/dotnet_enum.py reads component schemas out of Cosmoteer.dll
|
||
tools/ildump.py disassembles component methods to readable CIL
|
||
install.sh author's dev loop: validate + copy into the Mods folder
|
||
```
|
||
|
||
## Install
|
||
|
||
### Steam Workshop (any OS)
|
||
|
||
Subscribe to **Auto Radiator** on the Steam Workshop and enable it in the
|
||
game's Mods menu. Steam places it under
|
||
`steamapps/workshop/content/799600/<item id>/` and the game loads it from
|
||
there; no files to copy.
|
||
|
||
*Workshop link: pending first upload.*
|
||
|
||
### Manual (any OS)
|
||
|
||
Copy `mod.rules`, `logo.png` and the `components/` folder into a folder named
|
||
`auto_radiator` inside your user Mods folder, then enable it in the Mods menu:
|
||
|
||
| | Mods folder |
|
||
|---|---|
|
||
| Windows | `%USERPROFILE%\Saved Games\Cosmoteer\<SteamID>\Mods\` |
|
||
| Linux (Proton) | `<Steam library>/steamapps/compatdata/799600/pfx/drive_c/users/steamuser/Saved Games/Cosmoteer/<SteamID>/Mods/` |
|
||
|
||
### From this repo (Linux, for development)
|
||
|
||
```sh
|
||
./install.sh
|
||
```
|
||
|
||
Validates against the installed game first, then copies the same file set the
|
||
Workshop uploader ships. `./install.sh --uninstall` removes it. Discovery reads
|
||
Steam's `libraryfolders.vdf`, so a second disk, Flatpak, Snap or the Debian
|
||
package all work; if it still cannot find your Mods folder, set
|
||
`COSMOTEER_MODS_DIR` (and `COSMOTEER_DATA` for the game data).
|
||
|
||
## Publishing to the Workshop
|
||
|
||
The uploader is built into the game; no `steamcmd` needed.
|
||
|
||
1. `./install.sh` so the current files sit in the user Mods folder.
|
||
2. In game: **Mods** → find *Auto Radiator* under *User Mods* → right-click →
|
||
**Upload to Steam Workshop**.
|
||
3. Fill name, description, preview image (defaults to `logo.png`), visibility
|
||
and game version range → **Start Upload**.
|
||
4. Updates use the same menu item; it asks for change notes. Bump `Version`
|
||
in `mod.rules` first — multiplayer and save syncing match on it.
|
||
|
||
The **entire** mod folder is uploaded, which is why `install.sh` copies only
|
||
`mod.rules`, `logo.png` and `components/`. The Workshop item is bound to the
|
||
mod `ID`; keep `totenknopf.auto_radiator` stable. Steam does not refresh the
|
||
preview image on later uploads, and rejects previews over 500 KB.
|
||
|
||
## Tests
|
||
|
||
```sh
|
||
python3 tests/test_mod.py # mod against installed game data
|
||
python3 tests/test_steam_paths.py # Steam library discovery, on fake layouts
|
||
```
|
||
|
||
Cosmoteer applies mod actions to vanilla data at load time. Some mistakes crash
|
||
the game on startup — an unresolvable path, a misspelled component `Type` — and
|
||
the suite catches those by parsing the installed game's `.rules` tree offline.
|
||
|
||
The more dangerous mistakes do not crash anything. They load perfectly and
|
||
silently do nothing; see **Status** below for the four this project hit and the
|
||
guard for each. The suite also self-checks its own parser against known vanilla
|
||
facts first, so a broken parser fails loudly instead of passing everything.
|
||
|
||
The game is found through Steam's library list (`tools/steam_paths.py`). Set
|
||
`COSMOTEER_DATA` to point at a different install, and `COSMOTEER_ASSEMBLY`
|
||
for the schema checks.
|
||
|
||
## How to tell it is working
|
||
|
||
Switch a radiator **OFF** with heat storage low. It shows the "powered off"
|
||
indicator and no steam plume while the network fills. As the heat gauge nears
|
||
full the plume starts and the gauge stalls around 90–95%, with no overheat
|
||
warning. Fire missiles, the gauge drops, the radiator goes quiet again.
|
||
|
||
Switch it **ON** and it runs constantly, exactly as in vanilla.
|
||
|
||
## Status
|
||
|
||
**Confirmed working.** Against game 0.30.4c on 2026-08-29, v4.0.0 in a real
|
||
fight: radiators switched OFF idle while the network fills, vent as it nears
|
||
full, the ship never overheats, and they go quiet again once missiles drain
|
||
the reserve. Switched ON they vent constantly, as in vanilla.
|
||
|
||
Earlier builds required a launcher to broadcast "thermal ammo in use" over the
|
||
heat network, which worked at load but did not follow the ammo selection live.
|
||
The player's switch expresses the intent more directly, so the broadcast was
|
||
removed in v4.0.0 and OFF now means "bank heat".
|
||
|
||
### What the assembly says
|
||
|
||
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 (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 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`
|
||
anchors on one component, and a `NetworkRouter` bridges that anchor to every
|
||
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.
|
||
|
||
### Tools
|
||
|
||
`tools/dotnet_enum.py` reads component schemas and enum members straight out of
|
||
`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.
|
||
|
||
## Possible additions
|
||
|
||
- A third switch position (ON / BANK / OFF) so a radiator can be fully
|
||
disabled, which needs a new `ChoiceID` wired into
|
||
`Data/gui/game/parts/part_toggles.rules`.
|
||
- Exposing the 5% / 10% band in the game UI rather than as file constants.
|