feat: v2.0.0 -- confirmed working, scaffolding removed
Confirmed in game. The radiator's thermal-ammo and hold signals both arrive, VentAllowed goes false, and the radiator idles with its manual switch still on -- heat banked for the launchers instead of vented. The self-test probes did their job: ThresholdToggle was fine, and a mod-defined category carries a value perfectly well. The EMP probe was simply a bad probe -- it read thermal_load_dissipation, which an idled radiator broadcasts as zero, so it was correctly reporting a real zero. Routing in v1.3.0 was the actual cure. Remove the probes and the diagnostic indicators. The repointed power indicator is the one cue a player needs: it now shows on a radiator the mod has idled. Add test_override_values_name_real_components, which catches an Overrides value naming a component that does not exist -- a fourth way to produce a mod that loads clean and does nothing, and one this very commit introduced while stripping the scaffolding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011oLWY1G6S8U35bi2FxVWni
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
Screenshot*.png
|
||||||
|
|||||||
@@ -115,84 +115,70 @@ removes it. If the script cannot find your Mods folder, set `COSMOTEER_MODS_DIR`
|
|||||||
python3 tests/test_mod.py
|
python3 tests/test_mod.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Cosmoteer applies mod actions to vanilla data at load time, and a path that does
|
Cosmoteer applies mod actions to vanilla data at load time. Some mistakes crash
|
||||||
not resolve, a component reference pointing at nothing, or a misspelled
|
the game on startup — an unresolvable path, a misspelled component `Type` — and
|
||||||
component `Type` all fail identically: the game crashes on startup and leaves a
|
the suite catches those by parsing the installed game's `.rules` tree offline.
|
||||||
log file. The suite parses the installed game's `.rules` tree and checks all
|
|
||||||
three offline. It also self-checks the parser against known vanilla facts first,
|
|
||||||
so a broken parser fails loudly instead of passing everything.
|
|
||||||
|
|
||||||
Set `COSMOTEER_DATA` to point at a different install.
|
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.
|
||||||
|
|
||||||
|
Set `COSMOTEER_DATA` to point at a different install, and `COSMOTEER_ASSEMBLY`
|
||||||
|
for the schema checks.
|
||||||
|
|
||||||
## How to tell it is working
|
## How to tell it is working
|
||||||
|
|
||||||
There is no separate "auto" mode and nothing to reset. The radiator's on/off
|
There is no separate "auto" mode and nothing to reset. The radiator's on/off
|
||||||
switch is an AND gate:
|
switch is an AND gate:
|
||||||
|
|
||||||
- switch **off** → radiator is off, and stays off. The mod never turns it back on.
|
- switch **off** -> radiator is off, and stays off. The mod never turns it back on.
|
||||||
- switch **on** → the automation decides, moment to moment.
|
- switch **on** -> the automation decides, moment to moment.
|
||||||
|
|
||||||
An auto-idled radiator shows the same "powered off" indicator icon as a manually
|
An auto-idled radiator shows the "powered off" indicator icon and stops emitting
|
||||||
switched-off one, and stops emitting its steam plume. That icon appearing on a
|
its steam plume. That icon on a radiator you did *not* switch off is the mod
|
||||||
radiator you did *not* switch off is the mod working.
|
holding heat back for your launchers.
|
||||||
|
|
||||||
## Reading the diagnostic icons
|
|
||||||
|
|
||||||
The radiator reports its own internal state through indicator icons. An icon is
|
|
||||||
drawn while its toggle is **off**, so:
|
|
||||||
|
|
||||||
| Icon on a radiator | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| 🔧 wrench (`MissingComponent`) | **Always expected.** Its absence means the mod's components never merged onto the part, and nothing else in the mod is running. |
|
|
||||||
| thermal missile | No thermal-ammo signal is reaching this radiator — no launcher on its heat network is set to Thermal Canister Missiles. |
|
|
||||||
| resource | No hold signal is reaching this radiator — no thermal battery on its network is asking it to hold. |
|
|
||||||
| power-off | The radiator is idle: either you switched it off, or the automation idled it. |
|
|
||||||
|
|
||||||
A correctly working setup, on a ship with a thermal launcher and a battery below
|
|
||||||
the vent ceiling, shows **the wrench alone**.
|
|
||||||
|
|
||||||
These make the failure legible rather than guessable: wrench missing points at
|
|
||||||
the patch mechanism, either other icon points at the specific signal that is
|
|
||||||
dead.
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Validated against game data version 0.30.4c, and confirmed to load cleanly
|
Working, confirmed in game on 2026-08-29 against game data version 0.30.4c.
|
||||||
(`Enabled mods: [User Folder] - ganter.auto_radiator`).
|
|
||||||
|
|
||||||
**v1.0.0 loaded without error and did nothing in game.** It broadcast its
|
Getting there took four builds that each loaded without a single error and did
|
||||||
signals with `NetworkToggle` / `NetworkToggleReceiver`. All three vanilla uses of
|
nothing, for four different reasons. Cosmoteer reports none of them, so each one
|
||||||
those types set `IncludeInPhysical = false` — they are blueprint stat-display
|
now has a mutation-verified test:
|
||||||
plumbing and carry no signal on a real ship, so every receiver read off, the
|
|
||||||
vent gate was permanently open, and radiators behaved exactly as in vanilla.
|
|
||||||
|
|
||||||
**v1.1.0** carried the signals as `NetworkValue` / `NetworkValueReceiver`
|
| Silent failure | Guard |
|
||||||
instead, which is what vanilla's thermal amplification pump uses to drive actual
|
|---|---|
|
||||||
physical behaviour. That was necessary but not sufficient: it also loaded
|
| `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 |
|
||||||
cleanly and did nothing.
|
| 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 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` |
|
||||||
|
|
||||||
**v1.2.0** added the diagnostic indicator icons, which is what finally located
|
The third was the actual cause. A network component is only attached if a
|
||||||
the fault. The wrench icon was present — so `AddBase` and the `Overrides` had
|
**route** reaches it: the part's `NetworkPort` anchors on one component, and a
|
||||||
been applying correctly the whole time — while both signal icons showed,
|
`NetworkRouter` bridges that anchor to every other network component on the
|
||||||
meaning both `NetworkValue`s read zero.
|
part. That is how vanilla connects the radiator's own
|
||||||
|
|
||||||
**v1.3.0** fixes the actual cause. A network component is only attached to the
|
|
||||||
network 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
|
`NetworkedThermalDissipationLoadValue`, and how the amplification pump reaches
|
||||||
the resonance beam turret. Unrouted components are not an error — they simply
|
the resonance beam turret. Routes now run from each part's anchor —
|
||||||
never connect and read as absent forever.
|
`NetworkedHeatBuffer` on the radiator through its existing `Router`, plus a new
|
||||||
|
`NetworkRouter` on the thermal battery, the missile launcher and
|
||||||
|
`base_part_overclock`, none of which ships one.
|
||||||
|
|
||||||
Routes were added from each part's anchor: `NetworkedHeatBuffer` on the radiator
|
None of this is documented: the wiki's modding pages cover no network components
|
||||||
through its existing `Router`, plus a new `NetworkRouter` on the thermal
|
at all, so the game's own `.rules` files are the only reference.
|
||||||
battery, the missile launcher and `base_part_overclock`, none of which ships
|
|
||||||
one.
|
|
||||||
|
|
||||||
Three separate mechanisms have now each produced a mod that loads without a
|
`tools/dotnet_enum.py` reads component schemas and enum members straight out of
|
||||||
single error and silently does nothing. The test suite has a mutation-verified
|
`Cosmoteer.dll`, which is how the field and type questions got settled. It is
|
||||||
guard for each: blueprint-only signal types, fields that do not exist in the
|
useful on its own for any Cosmoteer modding:
|
||||||
real schema, and unrouted network components.
|
|
||||||
|
```sh
|
||||||
|
python3 tools/dotnet_enum.py /path/to/Cosmoteer.dll PartThresholdToggleRules
|
||||||
|
python3 tools/dotnet_enum.py /path/to/Cosmoteer.dll MultiToggleMode
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Possible additions
|
||||||
|
|
||||||
|
|||||||
@@ -72,67 +72,6 @@ AutoRad_ForceVent
|
|||||||
Invert = true
|
Invert = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Self-test probes -------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Three versions of this mod have now loaded cleanly and done nothing, each for
|
|
||||||
// a different reason, so these bisect the remaining possibilities directly.
|
|
||||||
// Each drives an indicator, and an indicator is drawn while its toggle is OFF,
|
|
||||||
// so in every case a visible icon means that probe FAILED.
|
|
||||||
|
|
||||||
// A: does ThresholdToggle behave as expected at all? No network involved.
|
|
||||||
AutoRad_ProbeOne { Type = StaticValue; Value = 1 }
|
|
||||||
AutoRad_ProbeThresholdOK
|
|
||||||
{
|
|
||||||
Type = ThresholdToggle
|
|
||||||
ValueFrom = AutoRad_ProbeOne
|
|
||||||
MinAmount = 0
|
|
||||||
MaxAmount = 0
|
|
||||||
Invert = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// B: can a mod-defined category carry a value at all? The radiator broadcasts
|
|
||||||
// and receives this one itself, so no second part is involved.
|
|
||||||
AutoRad_ProbeBroadcast
|
|
||||||
{
|
|
||||||
Type = NetworkValue
|
|
||||||
ValueSource = AutoRad_ProbeOne
|
|
||||||
Categories = [ auto_radiator_selftest ]
|
|
||||||
IncludeInBlueprints = false
|
|
||||||
}
|
|
||||||
AutoRad_ProbeReceive
|
|
||||||
{
|
|
||||||
Type = NetworkValueReceiver
|
|
||||||
Categories = [ auto_radiator_selftest ]
|
|
||||||
Mode = Max
|
|
||||||
AllowUncategorized = false
|
|
||||||
}
|
|
||||||
AutoRad_ProbeSelfLoopOK
|
|
||||||
{
|
|
||||||
Type = ThresholdToggle
|
|
||||||
ValueFrom = AutoRad_ProbeReceive
|
|
||||||
MinAmount = 0
|
|
||||||
MaxAmount = 0
|
|
||||||
Invert = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// C: does receiving work for a category vanilla already broadcasts? The
|
|
||||||
// radiator's own NetworkedThermalDissipationLoadValue publishes this one.
|
|
||||||
AutoRad_ProbeVanillaCat
|
|
||||||
{
|
|
||||||
Type = NetworkValueReceiver
|
|
||||||
Categories = [ thermal_load_dissipation ]
|
|
||||||
Mode = Max
|
|
||||||
AllowUncategorized = false
|
|
||||||
}
|
|
||||||
AutoRad_ProbeVanillaCatOK
|
|
||||||
{
|
|
||||||
Type = ThresholdToggle
|
|
||||||
ValueFrom = AutoRad_ProbeVanillaCat
|
|
||||||
MinAmount = 0
|
|
||||||
MaxAmount = 0
|
|
||||||
Invert = true
|
|
||||||
}
|
|
||||||
|
|
||||||
AutoRad_VentAllowed
|
AutoRad_VentAllowed
|
||||||
{
|
{
|
||||||
Type = MultiToggle
|
Type = MultiToggle
|
||||||
@@ -146,21 +85,11 @@ AutoRad_VentAllowed
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The replacement gate. PowerToggle is the radiator's vanilla UIToggle, so the
|
// The replacement gate. PowerToggle is the radiator's vanilla UIToggle, so the
|
||||||
// player's switch remains the master control.
|
// player's switch remains the master control: it can turn a radiator off, and
|
||||||
|
// the automation can only idle one the player has left on.
|
||||||
AutoPowerToggle
|
AutoPowerToggle
|
||||||
{
|
{
|
||||||
Type = MultiToggle
|
Type = MultiToggle
|
||||||
Toggles = [ PowerToggle, AutoRad_VentAllowed ]
|
Toggles = [ PowerToggle, AutoRad_VentAllowed ]
|
||||||
Mode = All
|
Mode = All
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic anchor. An indicator icon shows while its toggle is OFF, so an
|
|
||||||
// icon bound to this one is visible whenever these components were merged onto
|
|
||||||
// the part at all. If that icon is absent, the AddBase in mod.rules did not
|
|
||||||
// take effect and nothing else in this file is running either -- which is the
|
|
||||||
// single most useful thing to be able to tell apart from a dead signal.
|
|
||||||
AutoRad_AlwaysOff
|
|
||||||
{
|
|
||||||
Type = StaticToggle
|
|
||||||
ToggleOn = false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
ID = ganter.auto_radiator
|
ID = ganter.auto_radiator
|
||||||
Name = "Auto Radiator"
|
Name = "Auto Radiator"
|
||||||
Version = 1.4.0
|
Version = 2.0.0
|
||||||
CompatibleGameVersions = ["0.30.4c", "0.30.4"]
|
CompatibleGameVersions = ["0.30.4c", "0.30.4"]
|
||||||
ModifiesGameplay = true
|
ModifiesGameplay = true
|
||||||
Author = "Tobias Ganter"
|
Author = "Tobias Ganter"
|
||||||
@@ -85,9 +85,6 @@ Actions
|
|||||||
[NetworkedHeatBuffer, AutoRad_HoldSignal, 0]
|
[NetworkedHeatBuffer, AutoRad_HoldSignal, 0]
|
||||||
[NetworkedHeatBuffer, AutoRad_ThermalAmmoSignal, 0]
|
[NetworkedHeatBuffer, AutoRad_ThermalAmmoSignal, 0]
|
||||||
[NetworkedHeatBuffer, AutoRad_ForceVentSignal, 0]
|
[NetworkedHeatBuffer, AutoRad_ForceVentSignal, 0]
|
||||||
[NetworkedHeatBuffer, AutoRad_ProbeBroadcast, 0]
|
|
||||||
[NetworkedHeatBuffer, AutoRad_ProbeReceive, 0]
|
|
||||||
[NetworkedHeatBuffer, AutoRad_ProbeVanillaCat, 0]
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,78 +137,4 @@ Actions
|
|||||||
Toggle = AutoPowerToggle
|
Toggle = AutoPowerToggle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Diagnostic indicators -------------------------------------------
|
|
||||||
//
|
|
||||||
// An indicator icon is drawn while its toggle is OFF. These three make the
|
|
||||||
// radiator report its own internal state, so a radiator that is not
|
|
||||||
// behaving can be read directly instead of guessed at:
|
|
||||||
//
|
|
||||||
// wrench icon always visible -> these components merged onto the part
|
|
||||||
// absent -> the AddBase did not take effect
|
|
||||||
// thermal missile visible -> no thermal-ammo signal is arriving
|
|
||||||
// resource icon visible -> no hold signal is arriving from a battery
|
|
||||||
//
|
|
||||||
// A working setup on a ship with a thermal launcher and a battery below the
|
|
||||||
// vent ceiling shows the wrench alone.
|
|
||||||
{
|
|
||||||
Action = AddMany
|
|
||||||
AddTo = "<ships/terran/radiator/radiator.rules>/Part/Components/Indicators/Indicators"
|
|
||||||
ManyToAdd
|
|
||||||
[
|
|
||||||
{
|
|
||||||
Toggle = AutoRad_AlwaysOff
|
|
||||||
AtlasSprite
|
|
||||||
{
|
|
||||||
File = &/INDICATORS/MissingComponent
|
|
||||||
Size = [1, 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{
|
|
||||||
Toggle = AutoRad_ThermalAmmoInUse
|
|
||||||
AtlasSprite
|
|
||||||
{
|
|
||||||
File = &/INDICATORS/NoThermalMissiles
|
|
||||||
Size = [1, 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{
|
|
||||||
Toggle = AutoRad_IsHolding
|
|
||||||
AtlasSprite
|
|
||||||
{
|
|
||||||
File = &/INDICATORS/NoResource
|
|
||||||
Size = [1, 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Probe A: mine icon visible -> ThresholdToggle itself misbehaves.
|
|
||||||
{
|
|
||||||
Toggle = AutoRad_ProbeThresholdOK
|
|
||||||
AtlasSprite
|
|
||||||
{
|
|
||||||
File = &/INDICATORS/NoMines
|
|
||||||
Size = [1, 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Probe B: crew icon visible -> a mod-defined category carries
|
|
||||||
// nothing, even broadcast and received on the same part.
|
|
||||||
{
|
|
||||||
Toggle = AutoRad_ProbeSelfLoopOK
|
|
||||||
AtlasSprite
|
|
||||||
{
|
|
||||||
File = &/INDICATORS/NoCrew
|
|
||||||
Size = [1, 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Probe C: EMP icon visible -> receiving fails even for a category
|
|
||||||
// vanilla already broadcasts on this very part.
|
|
||||||
{
|
|
||||||
Toggle = AutoRad_ProbeVanillaCatOK
|
|
||||||
AtlasSprite
|
|
||||||
{
|
|
||||||
File = &/INDICATORS/NoEmp
|
|
||||||
Size = [1, 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -532,6 +532,54 @@ def test_network_components_are_routed():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6d. An Overrides value that names a component must name one that exists.
|
||||||
|
#
|
||||||
|
# The whole point of the Overrides actions is to repoint vanilla components
|
||||||
|
# at components this mod adds. If such a value names something that is not
|
||||||
|
# there -- deleted, renamed, or never written -- the mod still loads and the
|
||||||
|
# gate silently reverts to doing nothing.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_override_values_name_real_components():
|
||||||
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
||||||
|
|
||||||
|
# What each part offers: its vanilla components plus what the mod adds.
|
||||||
|
available = {}
|
||||||
|
for mod_file, part_path in addbase_targets():
|
||||||
|
added = rules_parser.parse_file(mod_file)
|
||||||
|
vanilla_components = resolve(f"<{part_path}>/Part/Components")
|
||||||
|
names = set(added.order)
|
||||||
|
if vanilla_components is not None:
|
||||||
|
names |= set(vanilla_components.order)
|
||||||
|
available.setdefault(part_path, set()).update(names)
|
||||||
|
|
||||||
|
for action in iter_actions(mod):
|
||||||
|
kind = action.get("Action")
|
||||||
|
target = action.get("OverrideIn")
|
||||||
|
if not kind or kind.value != "Overrides" or target is None:
|
||||||
|
continue
|
||||||
|
part_path, _inner = split_target(target.value)
|
||||||
|
known = available.get(part_path)
|
||||||
|
if not known:
|
||||||
|
continue
|
||||||
|
overrides = action.get("Overrides")
|
||||||
|
if overrides is None:
|
||||||
|
continue
|
||||||
|
for field in overrides.order:
|
||||||
|
value = overrides.children[field]
|
||||||
|
if value.kind != "scalar":
|
||||||
|
continue
|
||||||
|
ref = value.value.strip()
|
||||||
|
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", ref):
|
||||||
|
continue
|
||||||
|
check(
|
||||||
|
ref in known,
|
||||||
|
f"Overrides sets {field} = '{ref}' on {os.path.basename(part_path)}, "
|
||||||
|
f"but no such component exists on that part",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 7. Added indicators must bind to a real toggle and a real atlas sprite.
|
# 7. Added indicators must bind to a real toggle and a real atlas sprite.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user