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
|
seconds of continuous, total overflow** before fire risk even arms. Radiators
|
||||||
react within **0.5 s** (`HeatDissipationConverter { Interval = 0.5 }`).
|
react within **0.5 s** (`HeatDissipationConverter { Interval = 0.5 }`).
|
||||||
|
|
||||||
With ~2000 status heat of headroom left when venting starts, one overclocked
|
With 5% of one thermal battery (750 status heat) left when venting starts, one
|
||||||
large reactor takes about 0.7 s to consume it, against a 0.5 s radiator reaction
|
overclocked large reactor takes about 0.27 s to consume it, against a 0.5 s
|
||||||
time — so the overshoot lands well under the 350-per-tile threshold at which
|
radiator reaction time — so the overshoot lands well under the 350-per-tile
|
||||||
anything begins to happen. Holding the network near full is safe with a wide
|
threshold at which anything begins to happen. Holding the network near full is
|
||||||
margin, and headroom given up beyond that is ammunition thrown away for nothing.
|
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
|
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
|
(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**:
|
A radiator vents when **either**:
|
||||||
|
|
||||||
1. **The heat network is running out of room.** The radiator measures this
|
1. **The heat network is running out of room.** The radiator measures this
|
||||||
itself, with a bare `NetworkResourceInput` — the same way the heat exchanger
|
itself, with a bare `NetworkResourceInput` — the same component an
|
||||||
decides whether it can still push heat (`heat_exchanger.rules`:
|
overclocked part pushes its heat through (`base_part_overclock.rules`:
|
||||||
`IsNetworkHeatNonFull`). Vent below ~2000 status heat of remaining headroom,
|
`NetworkHeatInput`), so it sees exactly what a producer sees. Vent while
|
||||||
hold again above ~6000, a deadband so radiators do not chatter.
|
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
|
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
|
are not spending heat as ammunition there is nothing to hoard, so radiators
|
||||||
behave exactly as in vanilla.
|
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
|
The design is **fail-safe**: holding heat requires positive evidence that the
|
||||||
network has room to spare. Anything missing, disconnected or unmeasurable ends
|
network has room to spare. Anything missing, disconnected or unmeasurable ends
|
||||||
up venting, which is vanilla behaviour. Headroom is measured in absolute heat
|
up venting, which is vanilla behaviour.
|
||||||
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
|
On one thermal battery (15000 status heat) 5% free is 750 of headroom. An
|
||||||
large reactor.
|
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.
|
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
|
components/launcher_ammo_signal.rules reports thermal ammo in use
|
||||||
tests/ offline validation suite
|
tests/ offline validation suite
|
||||||
tools/dotnet_enum.py reads component schemas out of Cosmoteer.dll
|
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
|
install.sh validate + copy into the Mods folder
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,32 +146,50 @@ holding heat back for your launchers.
|
|||||||
|
|
||||||
## Status
|
## 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 in game: the gate closes and idles a radiator when thermal ammo is
|
||||||
- Confirmed: with no launcher on thermal ammo, behaviour is vanilla.
|
in use, and with no launcher on thermal ammo behaviour is vanilla.
|
||||||
- **Not yet confirmed: the release path.** v2.0.0 held heat *forever* — thermal
|
- **Not yet confirmed: the release path** — that radiators start venting as the
|
||||||
batteries visibly full, parts taking heat damage, radiators still idle.
|
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
|
### What the assembly says
|
||||||
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.
|
|
||||||
|
|
||||||
Getting this far took five builds that each loaded without a single error and
|
Nothing about these components is documented, and guessing cost six builds.
|
||||||
misbehaved silently. Cosmoteer reports none of these, so each has a
|
`tools/ildump.py` disassembles `Cosmoteer.dll`, and three methods settle how
|
||||||
mutation-verified test:
|
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 |
|
| 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 |
|
| `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` |
|
| 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` |
|
| 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
|
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`
|
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
|
own `NetworkedThermalDissipationLoadValue`, and how the amplification pump
|
||||||
reaches the resonance beam turret. Many parts ship no router at all.
|
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
|
### Tools
|
||||||
at all, so the game's own `.rules` files are the only reference.
|
|
||||||
|
|
||||||
`tools/dotnet_enum.py` reads component schemas and enum members straight out of
|
`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
|
`Cosmoteer.dll` with no dependencies:
|
||||||
useful on its own for any Cosmoteer modding:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 tools/dotnet_enum.py /path/to/Cosmoteer.dll PartThresholdToggleRules
|
python3 tools/dotnet_enum.py /path/to/Cosmoteer.dll PartThresholdToggleRules
|
||||||
python3 tools/dotnet_enum.py /path/to/Cosmoteer.dll MultiToggleMode
|
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
|
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
|
report its own internal state through indicator icons, if this ever needs
|
||||||
debugging again.
|
debugging again.
|
||||||
@@ -193,4 +225,4 @@ debugging again.
|
|||||||
|
|
||||||
- A per-radiator UI toggle for auto/manual, which needs a new `ChoiceID` wired
|
- A per-radiator UI toggle for auto/manual, which needs a new `ChoiceID` wired
|
||||||
into `Data/gui/game/parts/part_toggles.rules`.
|
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.
|
||||||
|
|||||||
@@ -18,41 +18,41 @@
|
|||||||
|
|
||||||
// --- Is the heat network running out of room? -------------------------------
|
// --- Is the heat network running out of room? -------------------------------
|
||||||
//
|
//
|
||||||
// Measured locally, exactly the way the heat exchanger decides whether it can
|
// Measured locally with a bare NetworkResourceInput, the same component an
|
||||||
// still push heat into the network (heat_exchanger.rules: IsNetworkHeatNonFull
|
// overclocked part pushes its heat through (base_part_overclock.rules:
|
||||||
// reads a bare NetworkResourceInput). A NetworkResourceInput's value is how much
|
// NetworkHeatInput), so this is exactly the producer's view of the network.
|
||||||
// the network can still accept.
|
|
||||||
//
|
//
|
||||||
// Reading a thermal battery's own HeatStorage does NOT work for this. That
|
// Its Value, from Cosmoteer.dll (PartNetworkResourceInput.get_Value, see
|
||||||
// storage is wrapped by a NetworkResourceStore, and v2.0.0 held heat forever --
|
// tools/ildump.py), is the FREE FRACTION of the connected heat storage:
|
||||||
// batteries visibly full, parts cooking -- because a local percentage threshold
|
// 1 - stored / total capacity -> 1.0 empty, 0.0 full, 0 if nothing attached
|
||||||
// on it never crossed. This measures the network itself instead, from the part
|
// and a ThresholdToggle compares that against MinAmount/MaxAmount as plain
|
||||||
// that acts on the answer, so no cross-part broadcast is involved at all.
|
// floats, so the amounts below are fractions too ("5%" is 0.05).
|
||||||
//
|
|
||||||
// Amounts are resource units: 1 status heat = 1000 resource units
|
|
||||||
// (statuses/heat/heat.rules: STATUS_TO_RESOURCE_RATIO).
|
|
||||||
|
|
||||||
AutoRad_NetworkSpace
|
AutoRad_NetworkSpace
|
||||||
{
|
{
|
||||||
Type = NetworkResourceInput
|
Type = NetworkResourceInput
|
||||||
ResourceType = heat
|
ResourceType = heat
|
||||||
Mode = DistributeProportionallyByAvailable
|
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
|
AutoRad_SpaceLow
|
||||||
{
|
{
|
||||||
Type = ThresholdToggle
|
Type = ThresholdToggle
|
||||||
ValueFrom = AutoRad_NetworkSpace
|
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
|
AutoRad_SpaceAmple
|
||||||
{
|
{
|
||||||
Type = ThresholdToggle
|
Type = ThresholdToggle
|
||||||
ValueFrom = AutoRad_NetworkSpace
|
ValueFrom = AutoRad_NetworkSpace
|
||||||
MinAmount = 6000000
|
HOLD_ABOVE_FREE = 10%
|
||||||
|
MinAmount = &HOLD_ABOVE_FREE
|
||||||
}
|
}
|
||||||
|
|
||||||
AutoRad_LowTrigger
|
AutoRad_LowTrigger
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
ID = ganter.auto_radiator
|
ID = ganter.auto_radiator
|
||||||
Name = "Auto Radiator"
|
Name = "Auto Radiator"
|
||||||
Version = 2.1.0
|
Version = 3.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"
|
||||||
|
|||||||
@@ -425,6 +425,10 @@ def test_component_fields_exist_in_schema():
|
|||||||
for field in component.order:
|
for field in component.order:
|
||||||
if field == "Type":
|
if field == "Type":
|
||||||
continue
|
continue
|
||||||
|
# ALL_CAPS entries are local constants for &references, the
|
||||||
|
# vanilla convention (thermal_battery.rules: MAX_STATUS_HEAT).
|
||||||
|
if field.isupper():
|
||||||
|
continue
|
||||||
check(
|
check(
|
||||||
field in schema,
|
field in schema,
|
||||||
f"{name}: {component_name} ({type_node.value}) has no field "
|
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.
|
# 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():
|
def test_manifest_is_well_formed():
|
||||||
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
|
||||||
for field in ("ID", "Name", "Version", "CompatibleGameVersions", "ModifiesGameplay"):
|
for field in ("ID", "Name", "Version", "CompatibleGameVersions", "ModifiesGameplay"):
|
||||||
|
|||||||
Executable
+139
@@ -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 <Cosmoteer.dll> <Type[,Type...]> [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 <Cosmoteer.dll> <Type[,Type]> [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))
|
||||||
Reference in New Issue
Block a user