diff --git a/.gitignore b/.gitignore index 7a60b85..7d54e43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__/ *.pyc +Screenshot*.png diff --git a/README.md b/README.md index c94c6c5..4251c82 100644 --- a/README.md +++ b/README.md @@ -115,84 +115,70 @@ removes it. If the script cannot find your Mods folder, set `COSMOTEER_MODS_DIR` python3 tests/test_mod.py ``` -Cosmoteer applies mod actions to vanilla data at load time, and a path that does -not resolve, a component reference pointing at nothing, or a misspelled -component `Type` all fail identically: the game crashes on startup and leaves a -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. +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. -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 There is no separate "auto" mode and nothing to reset. The radiator's on/off switch is an AND gate: -- switch **off** → radiator is off, and stays off. The mod never turns it back on. -- switch **on** → the automation decides, moment to moment. +- switch **off** -> radiator is off, and stays off. The mod never turns it back on. +- switch **on** -> the automation decides, moment to moment. -An auto-idled radiator shows the same "powered off" indicator icon as a manually -switched-off one, and stops emitting its steam plume. That icon appearing on a -radiator you did *not* switch off is the mod working. - -## 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. +An auto-idled radiator shows the "powered off" indicator icon and stops emitting +its steam plume. That icon on a radiator you did *not* switch off is the mod +holding heat back for your launchers. ## Status -Validated against game data version 0.30.4c, and confirmed to load cleanly -(`Enabled mods: [User Folder] - ganter.auto_radiator`). +Working, confirmed in game on 2026-08-29 against game data version 0.30.4c. -**v1.0.0 loaded without error and did nothing in game.** It broadcast its -signals with `NetworkToggle` / `NetworkToggleReceiver`. All three vanilla uses of -those types set `IncludeInPhysical = false` — they are blueprint stat-display -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. +Getting there took four builds that each loaded without a single error and did +nothing, for four different reasons. Cosmoteer reports none of them, so each one +now has a mutation-verified test: -**v1.1.0** carried the signals as `NetworkValue` / `NetworkValueReceiver` -instead, which is what vanilla's thermal amplification pump uses to drive actual -physical behaviour. That was necessary but not sufficient: it also loaded -cleanly and did nothing. +| 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 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 fault. The wrench icon was present — so `AddBase` and the `Overrides` had -been applying correctly the whole time — while both signal icons showed, -meaning both `NetworkValue`s read zero. - -**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 +The third was the actual cause. 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. Unrouted components are not an error — they simply -never connect and read as absent forever. +the resonance beam turret. Routes now run from each part's anchor — +`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 -through its existing `Router`, plus a new `NetworkRouter` on the thermal -battery, the missile launcher and `base_part_overclock`, none of which ships -one. +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. -Three separate mechanisms have now each produced a mod that loads without a -single error and silently does nothing. The test suite has a mutation-verified -guard for each: blueprint-only signal types, fields that do not exist in the -real schema, and unrouted network components. +`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: + +```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 diff --git a/components/radiator_auto.rules b/components/radiator_auto.rules index 298ece3..22c22e6 100644 --- a/components/radiator_auto.rules +++ b/components/radiator_auto.rules @@ -72,67 +72,6 @@ AutoRad_ForceVent 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 { Type = MultiToggle @@ -146,21 +85,11 @@ AutoRad_VentAllowed } // 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 { Type = MultiToggle Toggles = [ PowerToggle, AutoRad_VentAllowed ] 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 -} diff --git a/mod.rules b/mod.rules index 2744a47..a0e8b2f 100644 --- a/mod.rules +++ b/mod.rules @@ -16,7 +16,7 @@ ID = ganter.auto_radiator Name = "Auto Radiator" -Version = 1.4.0 +Version = 2.0.0 CompatibleGameVersions = ["0.30.4c", "0.30.4"] ModifiesGameplay = true Author = "Tobias Ganter" @@ -85,9 +85,6 @@ Actions [NetworkedHeatBuffer, AutoRad_HoldSignal, 0] [NetworkedHeatBuffer, AutoRad_ThermalAmmoSignal, 0] [NetworkedHeatBuffer, AutoRad_ForceVentSignal, 0] - [NetworkedHeatBuffer, AutoRad_ProbeBroadcast, 0] - [NetworkedHeatBuffer, AutoRad_ProbeReceive, 0] - [NetworkedHeatBuffer, AutoRad_ProbeVanillaCat, 0] ] } @@ -140,78 +137,4 @@ Actions 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 = "/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] - } - } - ] - } ] diff --git a/tests/test_mod.py b/tests/test_mod.py index 750965a..36d503c 100644 --- a/tests/test_mod.py +++ b/tests/test_mod.py @@ -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. # ---------------------------------------------------------------------------