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:
2026-08-29 01:23:02 +02:00
co-authored by Claude Fable 5
parent da331e955c
commit c5ddc78495
5 changed files with 294 additions and 52 deletions
+71
View File
@@ -425,6 +425,10 @@ def test_component_fields_exist_in_schema():
for field in component.order:
if field == "Type":
continue
# ALL_CAPS entries are local constants for &references, the
# vanilla convention (thermal_battery.rules: MAX_STATUS_HEAT).
if field.isupper():
continue
check(
field in schema,
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.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 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():
mod = rules_parser.parse_file(os.path.join(MOD_ROOT, "mod.rules"))
for field in ("ID", "Name", "Version", "CompatibleGameVersions", "ModifiesGameplay"):