CombustionDeepMiner¶
Vanilla game class behind the in-game "Combustion Deep Miner." An internal-combustion drill: two pipe inputs (fuel + oxidizer arrive already mixed in the pipe network), two player-facing "levers" (Throttle and CombustionLimiter), an internal 2 L combustion chamber with ignition threshold and stress-based failure.
Class hierarchy¶
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.CombustionDeepMiner
public class CombustionDeepMiner : DeepMiner, IInternalCombustion, IReferencable, IEvaluable
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeepMiner
public class DeepMiner : DeviceInputOutputImportExportCircuit, IGenerateMinables
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeviceInputOutputImportExportCircuit
public class DeviceInputOutputImportExportCircuit : DeviceInputOutputImportExport, ICircuitHolder, IDensePoolable
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeviceInputOutputImportExport
public class DeviceInputOutputImportExport : DeviceInputOutputImport
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeviceInputOutput
public class DeviceInputOutput : DeviceAtmospherics
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeviceAtmospherics
public class DeviceAtmospherics : Device, ISmartRotatable
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.Device
public class Device : SmallGrid, ILogicable, IReferencable, IEvaluable, IConnected,
ISlotWriteable, IWreckage, IPowered, IDensePoolable
Full chain: CombustionDeepMiner -> DeepMiner -> DeviceInputOutputImportExportCircuit -> DeviceInputOutputImportExport -> DeviceInputOutputImport -> DeviceInputOutput -> DeviceAtmospherics -> Device -> SmallGrid -> ... -> Thing.
Who owns what:
CombustionDeepMinerowns the combustion-state surface (Throttle,CombustionLimiter,Rpm,Stresslogic entries and setter routing), audio (_motorAudio,_stressAudio,_maxStressAudio,_gear2Audio,_gear4Audio), the_rpmStressLock, the stress-dial UI, and theOnAtmosphericTickoverride that drives theInternalCombustionstate machine.DeepMinerowns the drill-down animation (DRILL_DOWN_SPEED = 0.125f), voxel removal, ore spawning (DirtyOreprefab,_spawnOrePrefab),CanMine()/ThingInTheWay,Processingbyte (0..100),OreSpawnTime,_deepMinablesreagent selection fromWorldSetting.Current.Data.DeepMinablesData,Rpm(virtual, returns 200 on base; overridden byCombustionDeepMinerto return_internalCombustion.Rpm),RpmNormalised(float rpm) => rpm / 200f,ProgressProcessing, and the gear/drillshaft audio + wobble animation.DeviceInputOutputImportExportCircuitowns the embeddedProgrammableChipslot circuit and import/export chute behavior.DeviceInputOutputownsInputNetwork/InputNetwork2/OutputNetwork/OutputNetwork2PipeNetworkfields and the enormousCanLogicReadcase list forPressureInput/TemperatureInput/RatioXxxInput/ ... /PressureOutput/ ... variants (these are pipe-network readings, distinct from the chamber's internal readings).DeviceAtmosphericsowns[FormerlySerializedAs("PressurePerTick")] protected float pressurePerTick = 101.325fand thePressurekPa PressurePerTick => new PressurekPa(pressurePerTick)property used byInternalCombustion.HandleGasOutputto cap vent transfer moles per tick. The serialized default is 101.325 kPa (1 atm) per atmospheric tick, which governs how fast overpressure can drain into the output pipe.DeviceownsOnOff,Powered,Error, theInternalAtmospherefield, and the genericCanLogicRead/GetLogicValuerouting forLogicType.Pressure/LogicType.Temperature/LogicType.TotalMoles/LogicType.RatioXxx(gated onHasReadableAtmosphere). This is whyLogicType.PressureandLogicType.Temperaturework onCombustionDeepMiner(which setsHasReadableAtmosphere => true) and return the chamber's values, not the input pipe's.
Internal chamber comes from CombustionDeepMiner.InitInternalAtmosphere() (the class overrides DeepMiner's InitInternalAtmosphere with its own 2.0 L VolumeLitres Volume static field, not inherited via DeepMiner.InitInternalAtmosphere):
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.CombustionDeepMiner
private static readonly VolumeLitres Volume = new VolumeLitres(2.0);
...
public override void InitInternalAtmosphere()
{
if (base.InternalAtmosphere == null)
{
base.InternalAtmosphere = new Atmosphere(this, Volume, 0L);
}
}
The combustion state machine is NOT a direct method set on CombustionDeepMiner. It lives on a separately decompiled class InternalCombustion in the default (empty) namespace, referenced via private InternalCombustion _internalCombustion. CombustionDeepMiner constructs it in Awake:
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.CombustionDeepMiner.Awake
public override void Awake()
{
base.Awake();
if (!IsCursor)
{
_internalCombustion = new InternalCombustion(this);
}
...
}
InternalCombustion takes IInternalCombustion parent in its constructor (CombustionDeepMiner implements that interface), so the helper reads Parent.InternalAtmosphere, Parent.GetAsThing, Parent.ThrottleLever, Parent.CombustionLever via the interface.
The two levers¶
Both levers live on InternalCombustion, clamp 0..100, snap to multiples of 10, and expose themselves through LogicType.Throttle / LogicType.CombustionLimiter. The deep miner does not expose LogicType.Setting (blocked by CanLogicRead / CanLogicWrite).
Throttle (fuel intake valve)¶
Controls how much gas the chamber pulls from the input pipe each atmospheric tick. Linear.
// InternalCombustion.Throttle
private float _throttle;
public float Throttle {
get { return _throttle; }
set {
value = Mathf.Clamp(value, 0f, 100f);
value = Mathf.Round(value / 10f) * 10f;
float throttle = Throttle;
_throttle = value;
AnimateThrottle(throttle).Forget();
if (NetworkManager.IsServer) Parent.NetworkUpdateFlags |= 4096;
}
}
Read in the intake path:
// InternalCombustion.HandleGasInput
public void HandleGasInput(PipeNetwork inputNetwork)
{
Thing getAsThing = Parent.GetAsThing;
if (getAsThing.OnOff && getAsThing.Powered && getAsThing.Error <= 0)
{
MoleQuantity transferMoles = MaxMolarInput * (Throttle / 100f);
GasMixture gasMixture = inputNetwork.Atmosphere.Remove(transferMoles, AtmosphereHelper.MatterState.All);
Parent.InternalAtmosphere.Add(gasMixture);
}
}
MaxMolarInput = 0.009999999776482582 moles per tick. At Throttle=100 the chamber pulls 0.01 mol/tick; at Throttle=50, 0.005 mol/tick; at 0, nothing.
CombustionLimiter (burn fraction)¶
Controls what fraction of the gas already inside the chamber is burned this tick. Quadratic.
// InternalCombustion.CombustionLimiter
private float _combustionLimiter;
public float CombustionLimiter {
get { return _combustionLimiter; }
set {
value = Mathf.Clamp(value, 0f, 100f);
value = Mathf.Round(value / 10f) * 10f;
float combustionLimiter = CombustionLimiter;
_combustionLimiter = value;
AnimateCombustionLimiter(combustionLimiter).Forget();
if (NetworkManager.IsServer) Parent.NetworkUpdateFlags |= 8192;
}
}
Read in the burn path:
// InternalCombustion.ManualCombust
public void ManualCombust()
{
_normalCombustionEnergyCache = Parent.InternalAtmosphere.CombustionEnergy;
Parent.InternalAtmosphere.TryCombust(
Mathf.Clamp(Mathf.Pow(CombustionLimiter / 10f, 2f) * 0.0075f, 0.001f, 1f),
force: true
);
}
Burn-fraction table (exact values of Clamp(Pow(CL/10, 2) * 0.0075, 0.001, 1.0)):
| CombustionLimiter | Burn fraction |
|---|---|
| 0 | 0.001 (floor) |
| 10 | 0.0075 |
| 20 | 0.0300 |
| 30 | 0.0675 |
| 40 | 0.1200 |
| 50 | 0.1875 |
| 60 | 0.2700 |
| 70 | 0.3675 |
| 80 | 0.4800 |
| 90 | 0.6075 |
| 100 | 0.7500 (not clamped; 0.75 < 1.0) |
Peak burn is 75% of the chamber per tick at Limiter=100; the 1.0 clamp is never reached.
Interaction between the two¶
Throttle and Limiter are independent fields; nothing in code ties one to the other. They interact only through the shared internal atmosphere:
OnAtmosphericTickorder:ManualCombust()->SpeedTick()->HandleGasOutput()->HandleGasInput().ManualCombustburns a fraction of what is already in the chamber before new fuel enters.HandleGasOutputvents anything above 4000 kPa (_targetPressure) into the output pipe (10% of overpressure per tick).HandleGasInputthen admits up to0.01 * Throttle/100moles of new mixed gas.
Consequences:
- Throttle high + Limiter low: chamber fills faster than it burns, pressure climbs past 4000 kPa, vent loop dumps unburned fuel into the output pipe (wasted).
- Throttle low + Limiter high: chamber is burned down to near-empty before new fuel arrives; RPM accel fails the
CombustionEnergy + _normalCombustionEnergyCache >= 30check and stress accumulates. - Balanced: the ideal is
burned moles per tickroughly equal toadmitted moles per tick, with pressure sitting at or just below 4000 kPa and temperature in the RPM-accel band.
Tick pipeline¶
// CombustionDeepMiner.OnAtmosphericTick
public override void OnAtmosphericTick()
{
base.OnAtmosphericTick();
if (_internalCombustion == null) return;
lock (_rpmStressLock)
{
if (!IsOperable) { _internalCombustion.HandleShutDown(); return; }
if (!base.IsStructureCompleted) { _internalCombustion.Rpm = 0f; return; }
_internalCombustion.ManualCombust();
_internalCombustion.SpeedTick();
if (!OnOff || !Powered || Error == 1) _internalCombustion.HandleShutDown();
_internalCombustion.HandleGasOutput(base.PressurePerTick, InputNetwork, OutputNetwork);
_internalCombustion.HandleGasInput(InputNetwork);
if (OnOff && Powered) base.InternalAtmosphere.Sparked = true;
}
}
Atmospheric tick runs at 20 Hz (0.05 s), which is the cadence both levers operate on. Lever setters only animate the visual rotation; the control value itself updates instantly.
HandleGasInput is additionally gated on Error <= 0:
// InternalCombustion.HandleGasInput
public void HandleGasInput(PipeNetwork inputNetwork)
{
Thing getAsThing = Parent.GetAsThing;
if (getAsThing.OnOff && getAsThing.Powered && getAsThing.Error <= 0)
{
MoleQuantity transferMoles = MaxMolarInput * (Throttle / 100f);
GasMixture gasMixture = inputNetwork.Atmosphere.Remove(transferMoles, AtmosphereHelper.MatterState.All);
Parent.InternalAtmosphere.Add(gasMixture);
}
}
Consequence: when IsOperable flips false (stress hit 100 and set Error = 1, or a ThingInTheWay, or an input/output not valid, or the chip errored), the tick goes directly to HandleShutDown and returns early - no combustion, no input, no output for that tick. When the block clears (e.g. stress decays below 100 and AssessError / IsOperable flip back), the full tick chain resumes next atmospheric tick. Writes to Throttle / CombustionLimiter always take effect instantly (no multi-tick ramp on the control values themselves), but the RPM response lags because of the 0.99 friction coefficient: RPM halves over ln(0.5) / ln(0.99) ~ 69 ticks ~ 3.4 s in the absence of combustion energy.
See "Error states and shutdown" below for the verbatim IsOperable auto-toggle, every Error = 1 trigger, and the HandleShutDown body including the !_gainedStress gate that distinguishes stress-trip decay from non-stress-Error decay.
SpeedTick: RPM, friction, stress¶
// InternalCombustion.SpeedTick
public void SpeedTick()
{
_targetPressure = RunningTargetPressure;
float num = 0f;
MoleEnergy moleEnergy = new MoleEnergy(30.0);
float num2 = Rpm;
if (Parent.InternalAtmosphere.Temperature >= new TemperatureKelvin(573.15)
&& Parent.InternalAtmosphere.CombustionEnergy + _normalCombustionEnergyCache >= moleEnergy)
{
num = Mathf.Lerp(0f, 12f, Mathf.Clamp01(Parent.InternalAtmosphere.Temperature.ToFloat() / 5000f));
MoleEnergy energy = moleEnergy * num;
Parent.InternalAtmosphere.GasMixture.RemoveEnergy(energy);
num2 += num;
DidCombustionLastTick = true;
}
else { DidCombustionLastTick = false; }
float num3 = 0.99f; // 1% friction / tick
float num4 = num2 * num3;
float num5 = num2 - num4;
if (Parent.GetAsThing.OnOff && Parent.GetAsThing.Powered) Rpm = num4;
float num6 = Mathf.Abs(num - num5);
float num7 = Mathf.Lerp(0.8f, 2f, Mathf.Clamp01((Rpm - 300f) / 1200f));
bool flag = num6 > 1f / num7;
if (!_gainedStress && (!Parent.GetAsThing.OnOff || !Parent.GetAsThing.Powered)) flag = false;
if (flag) { Stress = Mathf.Clamp(Stress + (num6 - 1f) * num7, 0f, 100f); _gainedStress = true; }
else { Stress = Mathf.Clamp(Stress - 0.5f, 0f, 100f); _gainedStress = false; }
if (Stress >= 100f)
{
Rpm -= 40f;
if (Parent.GetAsThing.Error == 0) OnServer.Interact(Parent.GetAsThing.InteractError, 1);
}
}
Per-tick rules:
- Ignition floor: internal temperature must reach
573.15 K(300 C) before combustion contributes to RPM. - Energy floor:
CombustionEnergy + _normalCombustionEnergyCache >= 30 J(MoleEnergy(30.0), stored verbatim with no unit multiplier;CombustionEnergyis in SI joules, accumulated asMole.Enthalpy() * EnthalpyMultiplier * Quantity.ToDouble()whereEnthalpyis in J/mol). Below this, no RPM gain. A single tick of burn that consumes even ~0.0001 mol of H2 fuel produces ~30 J, so any fueled chamber above the 573.15 K temperature gate clears this floor trivially; it only closes when the chamber is essentially empty of fuel. - RPM accel coefficient:
num = Lerp(0, 12, T / 5000). Temperature buys more RPM per tick, maxing at 12 RPM/tick at 5000 K.Mathf.Clamp01is applied to the lerp fractionT/5000, not to T itself. Chamber temperature can rise freely above 5000 K (excess fuel enthalpy accumulates as heat and leaves via venting); num just saturates at 12. Any chamber temperature above 5000 K is thermodynamically wasted fuel as far as RPM is concerned. - Friction: each tick,
Rpm_new = 0.99 * (Rpm_old + num). At steady stateRpm_ss = 0.99 * (Rpm_ss + num), soRpm_ss = 99 * num. Atnum = 6, steady-state is 594 RPM. Atnum = 12(max, T >= 5000 K), steady-state is 1188 RPM. Absolute RPM ceiling is 1188, approached asymptotically; no code path pushes Rpm above this (theRpm -= 40stress-trip andRpm -= 15shutdown branches only subtract;_internalCombustion.Rpm = 0fon structure-incomplete is a zeroing;Rpm = num4in SpeedTick is the only additive path and is bounded by num <= 12). An earlier version of this page statedRpm_steady = 100 * num(ceiling 1200); that was a sign-error in the algebraic derivation and is corrected here. - Stress threshold: the code computes
num5 = num2 - num4 = 0.01 * (Rpm + num), thennum6 = |num - num5| = |0.99*num - 0.01*Rpm|. So the mismatch is|0.99*num - 0.01*Rpm|, and the stress trigger is|0.99*num - 0.01*Rpm| > 1 / Lerp(0.8, 2, (Rpm - 300) / 1200). The hysteresis tightens as RPM rises: at 300 RPM tolerance is1/0.8 = 1.25, at 1500 RPM tolerance is1/2 = 0.5. (An earlier version of this page wrote the mismatch as|num - 0.01*Rpm|; the 0.01*num correction is small in magnitude but matters at high num.) - Stress growth: when tripped,
Stress += (mismatch - 1) * hysteresisFactor. - Stress decay:
-0.5 per tickwhen not tripped. - Stress failure: at 100,
Rpm -= 40every tick (only the first setsError = 1, gated onError == 0). The stress branch does NOT route throughIsOperable; on the next atmospheric tickIsOperableauto-clearsErrorback to 0 if the physicalflag2conditions are good, and SpeedTick runs again. IfStressis still 100, the trip re-fires, subtracting another 40 RPM. This oscillates until the mismatch drops below threshold. See "Error states and shutdown" below for the full Error/shutdown machinery.
Error states and shutdown¶
Error = 1 on a running CombustionDeepMiner comes from two independent paths: the stress-100 branch in SpeedTick (documented above) and the IsOperable auto-set, which fires on non-stress physical conditions every atmospheric tick.
IsOperable auto-toggle¶
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.CombustionDeepMiner.IsOperable
protected override bool IsOperable
{
get
{
bool flag = (bool)base.ProgrammableChip &&
(CodeErrorState != 0 || base.ProgrammableChip.CompilationError);
bool flag2 = base.IsInputValid && base.IsOutputValid && !flag &&
CanMine() && base.IsStructureCompleted;
if (Error == 0 && !flag2)
OnServer.Interact(base.InteractError, 1);
else if (Error == 1 && flag2)
OnServer.Interact(base.InteractError, 0);
return flag2;
}
}
Evaluated at the top of every OnAtmosphericTick call (20 Hz). Auto-toggles Error:
Error = 0and!flag2-> setsError = 1.Error = 1andflag2-> setsError = 0.- Otherwise no Error write.
Returns flag2 (the physical-condition bit), independent of whether Error was just toggled. OnAtmosphericTick then uses that return: if (!IsOperable) { HandleShutDown(); return; }. So a physical failure short-circuits SpeedTick entirely for that tick.
IsInputValid / IsOutputValid: structural, no pressure minimum¶
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeviceInputOutput
public bool IsInputValid
{
get
{
if (InputNetwork != null && InputNetwork.IsNetworkValid())
return !InputNetwork.IsAwaitingEvent;
return false;
}
}
public bool IsOutputValid
{
get
{
if (OutputNetwork != null && OutputNetwork.IsNetworkValid())
return !OutputNetwork.IsAwaitingEvent;
return false;
}
}
public bool IsInput2Valid
{
get
{
if (InputNetwork2 != null && InputNetwork2.IsNetworkValid())
return !InputNetwork2.IsAwaitingEvent;
return false;
}
}
No override on DeviceInputOutputImport, DeviceInputOutputImportExport, DeviceInputOutputImportExportCircuit, DeepMiner, or CombustionDeepMiner — the base implementation is what the IsOperable gate sees.
Three structural checks, in order:
InputNetwork != null— the pipe is physically connected (connection exists). A disconnected pipe setsInputNetworkto null inCheckConnections()/OnRemovePipeNetwork().InputNetwork.IsNetworkValid()— the network has at least one structure (StructureList.Count > 0).!InputNetwork.IsAwaitingEvent— the network is not in the middle of a deferred rebuild event this frame.
No minimum pressure. No minimum mole count. No gas-type requirement. No matter-state (gas/liquid) requirement. No temperature requirement. An empty-but-connected pipe returns IsInputValid = true; downstream combustion simply sees zero moles at HandleGasInput and admits nothing.
IsOutputValid is symmetric on OutputNetwork. The HandleGasOutput vent path is gated by its own pressure comparison against _targetPressure, but that runs only if IsOutputValid already passed; no maximum-pressure check blocks IsOutputValid itself.
Quirk on the secondary input: CombustionDeepMiner does expose a second pipe connection (InputNetwork2) and the matching IsInput2Valid property, but IsOperable gates only on IsInputValid and IsOutputValid - not IsInput2Valid. A broken secondary input therefore does not trigger the Error auto-set via this path. InternalCombustion.HandleGasInput only consumes from the primary InputNetwork, so on vanilla behaviour the second input is not load-bearing for the combustion cycle.
Disconnected vs. empty-but-connected are different code paths: the first fails at the null check and returns false; the second passes all three checks and returns true.
Every path that sets Error = 1 on a running miner¶
| Source | Condition | Requires Stress = 100? | Clearing mechanism |
|---|---|---|---|
IsOperable |
!base.IsInputValid |
no | Auto-cleared by IsOperable next tick when pipe recovers |
IsOperable |
!base.IsOutputValid |
no | Auto-cleared next tick when pipe recovers |
IsOperable |
Chip CodeErrorState != 0 or CompilationError |
no | Auto-cleared next tick when chip state recovers |
IsOperable |
!CanMine() (DeepMiner-side gates: obstruction, missing _deepMinables, export chute, etc.) |
no | Auto-cleared next tick when condition recovers |
IsOperable |
!base.IsStructureCompleted |
no | Auto-cleared next tick when structure completes |
SpeedTick |
Stress >= 100 (if (Parent.Error == 0) Interact(InteractError, 1)) |
yes | Auto-cleared by IsOperable next tick if flag2 is true; but SpeedTick re-fires the trip the same tick while Stress stays >= 100 |
Every non-stress path is checked at the IsOperable gate and cuts the tick short before SpeedTick runs. The stress path is inside SpeedTick itself and therefore only evaluates when IsOperable already returned true for that tick.
HandleShutDown¶
// InternalCombustion.HandleShutDown
public void HandleShutDown()
{
if (!_gainedStress)
{
Rpm -= 15f;
_targetPressure = RocketMath.Lerp(PressurekPa.Zero, RunningTargetPressure,
Mathf.Clamp01(Rpm / 1000f));
}
}
The entire method. It only touches RPM / target-pressure when _gainedStress is false. Called from two sites in OnAtmosphericTick:
- Pre-tick short-circuit:
if (!IsOperable) { HandleShutDown(); return; }. Fires on any non-stress Error.SpeedTickdoes not run this tick, so_gainedStresskeeps its previous value (typically false on a miner that wasn't actively stressing), and RPM decays at -15/tick = 300 RPM/sec. - Mid-tick branch:
if (!OnOff || !Powered || Error == 1) HandleShutDown();runs afterSpeedTick. On a stress-100 trip,SpeedTickhas just set_gainedStress = true(the stress accumulation branch sets it), soHandleShutDownis a no-op. On an!OnOff/!Poweredcase,SpeedTickdid run this tick and_gainedStressdepends on whether mismatch exceeded threshold.
Two distinct failure-mode decay rates¶
- Non-stress Error (
IsOperablefails): -15 RPM/tick = 300 RPM/sec. Stress value is whatever it was when the Error fired; it does not update becauseSpeedTickis skipped. Chamber loses fuel intake (HandleGasInputis gated onError <= 0) but existing chamber gas continues to be vented byHandleGasOutputwith a target pressure that slides toward 0 as RPM falls. - Stress-100 trip: -40 RPM/tick = 800 RPM/sec while
Stress >= 100. Stress stays pinned at 100 until the chamber cools or empties enough that mismatch drops below1 / num7, then decays at -0.5/tick via normal SpeedTick. Error toggles 1 -> 0 -> 1 each tick (SpeedTick sets, next-tick IsOperable clears), which meansHandleGasInputis blocked on the set-Error half of each tick and admitted on the cleared half.
The two rates and their dial signatures differ:
- A stress trip pins
Stress = 100for multiple seconds during decay. The dial animator lerps toward live stress at rate 2/sec (time constant 0.5 s), so sustained stress=100 produces a visibly pegged needle within about 1.5 seconds. - A non-stress Error leaves stress at its pre-failure value. The needle does not rise.
Useful for diagnosing in-game: if the miner drops RPM fast while the stress dial never leaves the low band, the trigger is one of the five IsOperable paths, not a stress trip.
Stress dial and audio¶
Needle lerps toward the live stress value at rate 2/second, time constant 0.5 s. A single-tick spike (0.05 s) moves the needle only a few percent; sustained stress reaches ~95 % of full deflection in ~1.5 s. Audio thresholds read live stress directly (not the lerped needle): warning fires Stress > 40, max-stress sting fires Stress > 95 and stops at Stress < 90 (hysteresis band 90-95).
Startup transient: why a hot chamber explodes on any throttle¶
This section is derived analysis from the SpeedTick formulas above. Every number here follows from the verbatim code quoted in "SpeedTick: RPM, friction, stress"; no extra game data.
Key observation: num (RPM accel per tick) is a function of temperature only, not of throttle or limiter:
Throttle and Limiter control fuel mass flow and burn fraction, both of which drive temperature indirectly. Once the two gates are open (T >= 573.15 K and CombustionEnergy + _normalCombustionEnergyCache >= 30 J), num is set by whatever T currently is, and the same amount of RPM accel is applied regardless of how much fuel was actually burned this tick.
Stress accumulates on the mismatch between accel input (num) and friction loss (num5 = 0.01 * Rpm):
mismatch = |num - 0.01*Rpm|
num7 = Lerp(0.8, 2, Clamp01((Rpm - 300) / 1200)) # 0.8 at Rpm <= 300, 2.0 at Rpm >= 1500
threshold = 1 / num7
if mismatch > threshold: Stress += (mismatch - 1) * num7
else: Stress -= 0.5
At Rpm = 0, num7 = 0.8 and threshold = 1.25. So unless num <= 1.25 at Rpm = 0, stress accumulates on the first tick.
Temperature bands vs. cold-start tolerance¶
Cold-start means Rpm = 0 with combustion firing. num-at-ignition vs. stress outcome:
| Chamber T | num | mismatch at Rpm=0 | Stress/tick | Stress/sec (20 Hz) | Ticks to 100 |
|---|---|---|---|---|---|
| 573.15 K (ignition floor) | 1.375 | 1.375 | 0.30 | 6.0 | ~333 |
| 800 K | 1.92 | 1.92 | 0.74 | 14.7 | ~136 |
| 1200 K | 2.88 | 2.88 | 1.50 | 30.1 | ~67 |
| 2000 K | 4.80 | 4.80 | 3.04 | 60.8 | ~33 |
| 3000 K | 7.20 | 7.20 | 4.96 | 99.2 | ~20 |
| 4000 K | 9.60 | 9.60 | 6.88 | 137.6 | ~15 |
| 5000 K (cap) | 12.00 | 12.00 | 8.80 | 176.0 | ~12 |
The "Stress/sec" column makes the failure mode visible: anywhere above ~700 K the stress curve outruns RPM ramp entirely, because RPM catches up to num only at rate (num - 0.01*Rpm) per tick. The RPM transient is first-order with time constant ~5 s (friction 1%), but stress hits 100 in ~0.6 s at 5000 K. By the time RPM has risen enough for the mismatch to drop under threshold, stress has long since tripped Error = 1.
Why CombustionLimiter cannot stop this¶
CombustionLimiter scales burn fraction, not whether combustion fires. The SpeedTick gate is a pair of Boolean checks on the chamber state, not a magnitude check on this tick's burn:
T >= 573.15 Kdepends on chamber history, not on this tick's burn rate.CombustionEnergy + _normalCombustionEnergyCache >= 30 Jis a pre-burn-plus-post-burn sum of combustion energy available in the chamber. Even atCombustionLimiter = 0(burn fraction 0.001 floor), only 0.1% of chamber fuel is consumed per tick, so a hot chamber with any fuel load clears the 30-J floor trivially (30 J is roughly 0.0001 mol of H2 combusted).
Consequence: once the chamber is hot and has fuel, num fires at the full T-determined rate every tick, and Limiter has zero effect on num.
Why Throttle = 10 cannot stop this either¶
Throttle = 10 admits 0.001 mol/tick of fresh mixture. This is a tiny addition. It changes neither num (temperature-driven) nor the ignition-floor state (chamber already hot, already has fuel). The only thing Throttle changes during a hot-chamber start is how fast new fuel refills the chamber to replace what combustion consumes; at 0.001 mol/tick of intake vs. 0.0075 fraction/tick of burn, the chamber fuel load actually drains, but slowly.
Why Throttle = 0 for one tick does not reset anything¶
Setting Throttle = 0 halts HandleGasInput. The chamber's existing fuel keeps burning at CombustionLimiter's fraction. For the gates to close (combustion to stop firing), CombustionEnergy + _normalCombustionEnergyCache must fall below 30 J, which in practice means the chamber is essentially empty of fuel (30 J is ~0.0001 mol of H2). Time to drain the chamber of fuel to that point depends on burn fraction:
- At
CombustionLimiter = 0(burn fraction 0.001): half-life of fuel ~693 ticks = 34.7 s. - At
CombustionLimiter = 10(burn fraction 0.0075): half-life ~92 ticks = 4.6 s. - At
CombustionLimiter = 100(burn fraction 0.75): half-life ~0.5 ticks = 25 ms (effectively one tick).
So the fastest way to stop combustion on a hot chamber is counter-intuitive: raise CombustionLimiter to 100 (burns all fuel in one tick, 30-J gate closes next tick) rather than lower it. Note that this also dumps all the fuel's energy as heat in a single tick, which spikes temperature even higher, which spikes num even higher for that one tick. Net: one catastrophic tick followed by cold-gate closure.
The user's manual pulse strategy (Throttle=10, then Throttle=0 above 30% stress, repeat) works because:
- Throttle=10 admits enough fuel to keep combustion firing.
- RPM ramps during the "on" phase. Stress accumulates faster than RPM rises, so stress hits the 30% abort before RPM stabilises.
- Throttle=0 halts new fuel. Existing fuel burns at CL=10 rate, draining the chamber on a ~4.6 s half-life.
- During this drain, RPM is gaining until fuel drops below 30 MoleEnergy, then num flips to 0 and RPM decays by 1% per tick.
- Stress decays at 0.5/tick throughout the "off" phase (no fresh mismatch).
- Each cycle nets positive RPM gain if "on" adds more RPM than "off" loses, and net-zero stress if the decay phase is long enough.
It is a hand-tuned ratchet. It works only because the chamber manages to stay hot enough across cycles to keep num high, while RPM eventually crosses into the tolerant band (Rpm >= 300 where num7 increases, threshold widens only slightly from 1.25 to 0.5 by 1500 RPM).
The only safe cold-start¶
A cold start (ambient chamber, T around 300 K well below the 573.15 K ignition floor) avoids the transient entirely. num = 0 until T crosses ignition, and temperature rises slowly driven by fuel enthalpy over the chamber's heat capacity. As T crosses 573.15 K, num starts at ~1.37, mismatch at Rpm=0 is 1.37, stress gain is 0.30/tick = 6.0/sec. Rpm gains (num - 0) per tick initially, so mismatch falls as Rpm rises. For the mismatch-cross formula mismatch = num - 0.01*Rpm:
- At num = 1.37 and
mismatch = 1.25(the threshold at Rpm <= 300), Rpm must be 12. This happens in ~9 ticks at num=1.37 per tick into 0.99 friction.
So roughly half a second after ignition, stress stops accumulating, and whatever stress was accrued (max ~3) decays away. Temperature continues to rise as fuel burns, num rises with it, and RPM trails num smoothly because mismatch stays close to threshold on the whole ramp.
Regime summary:
- Cold chamber (T < 573.15 K): safe to open throttle and run up. Stress never spikes because num rises alongside Rpm.
- Warm chamber (573.15 K < T < ~700 K): marginal. Stress accumulates at single-digit ticks. Ramp is recoverable but close.
- Hot chamber (T > ~700 K): stress outruns Rpm. No fixed (Throttle, CombustionLimiter) pair stabilises the transient; only pulsing, manual recovery, or a forced cool-down works.
Practical operation rules for a governor that must start on an already-hot chamber:
- Detect the hot state before engaging. Read
Temperaturevia LogicType.Temperature (chamber-scoped perHasReadableAtmosphere => true). If T >= 573.15 K, skip straight to cool-down; do not write Throttle > 0. - Cool-down plan: set OnOff=0, Throttle=0, CombustionLimiter=0. HandleGasInput is gated on OnOff and Error; this freezes fuel admission. With OnOff=0, SpeedTick's
Rpm = num4update is also gated off, so Rpm holds rather than decaying through friction (a small plus). Wait for T to drop via natural chamber heat loss and venting. Temperature decay rate depends on the chamber's radiation and wall conduction parameters, which are not documented in this page; measure empirically. - Ramp from cold: once T < 573.15 K, set Throttle and CombustionLimiter from their minimum non-zero values. Raise slowly, observing Stress. The transient is safe as long as T remains below ~700 K during the first few hundred ticks.
- Do not pulse on hot chamber: pulsing works manually because a human can see the stress dial and abort within ~500 ms. An IC10 script runs at ~1 line/tick and cannot out-manoeuvre a 176 stress/sec spike once T = 5000 K. Pulse cycles are valid only below T ~= 2000 K where the pulse-off phase fits inside the stress-decay budget.
Higher-energy fuels make this worse only via temperature: H2+O3 vs CH4+O2 differ by a 1.5x energy ratio per mole of fuel and a 1.5x oxidiser multiplier. Higher energy per mole means the chamber heats up faster per mol burned, which pushes T toward 5000 K faster. The num ceiling is the same (12 at T >= 5000 K), but the time to reach that ceiling from a cold start is shorter with higher-energy fuel. The cold-start ramp is still safe; the hot-chamber failure mode is identical. The practical difference is smaller time windows: less time to react between ignition and running away.
Drill depth and ore yield¶
Drilling depth and ore production scale linearly with RPM via DeepMiner.RpmNormalised(rpm) = rpm / 200f. The base class constant STANDARD_RPM = 200f is the reference point: RPM 200 is "1.0x speed" for both the drill-down animation and the per-tick ore-spawn progress.
Drill-head descent (per-frame, off the main update):
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeepMiner.DrillDownAnimation
private void DrillDownAnimation()
{
...
_isReachedBedRock = vector.y <= 0f;
vector += Vector3.down * (Time.deltaTime * 0.125f * RpmNormalised(Rpm));
_drillBit.position = vector;
...
}
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeepMiner
private const float DRILL_DOWN_SPEED = 0.125f;
public static float RpmNormalised(float rpm) => rpm / 200f;
Descent rate: 0.125 m/s * (Rpm / 200f) = 0.000625 m/s per RPM. At RPM 200, that's 0.125 m/s (7.5 m/min). At RPM 600 (a typical steady-state on a combustion deep miner), 0.375 m/s. The drill stops descending once _drillBit.position.y <= 0f (bedrock reached).
Descent only runs in UpdateEachFrame when !_isReachedBedRock && OnOff && Powered && Error == 0:
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeepMiner.UpdateEachFrame
public override void UpdateEachFrame()
{
base.UpdateEachFrame();
if (!_isReachedBedRock && OnOff && Powered && Error == 0)
{
DrillDownAnimation();
}
...
}
Ore-spawn progress (server export tick, runs only after bedrock is reached):
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeepMiner.OnServerExportTick
protected override void OnServerExportTick(float deltaTime)
{
if (!OnOff || !Powered || !_isReachedBedRock || Error != 0 || !base.IsStructureCompleted)
{
return;
}
ProgressProcessing(deltaTime);
if (IsSpawnTime() && !base.IsExportChuteBlocked)
{
ResetSpawnTime();
SpawnOre();
if (CanBeginExport)
{
OnServer.Interact(base.InteractExport, 1);
}
}
}
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeepMiner.ProgressProcessing
protected void ProgressProcessing(float deltaTime)
{
if (OnOff && Powered)
{
_currentProgress += deltaTime * RpmNormalised(Rpm);
Processing = (byte)(Mathf.Clamp01(_currentProgress / OreSpawnTime) * 100f);
}
}
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeepMiner (ore-spawn timing)
private bool IsSpawnTime() => _currentProgress > OreSpawnTime;
private void ResetSpawnTime()
{
_currentProgress = 0f;
OreSpawnTime = _deepMinables.GetTimeToMine();
}
private void SpawnOre()
{
DirtyOre dirtyOre = Thing.Create<DirtyOre>(_spawnOrePrefab, ExportSlot.Location);
_deepMinables.SetValues(dirtyOre);
dirtyOre.ParentSlot = null;
OnServer.MoveToSlot(dirtyOre, ExportSlot);
}
Ore yield:
_currentProgressaccumulates in real-time seconds scaled byRpmNormalised(Rpm) = Rpm / 200f. At RPM 200 the progress bar fills at one unit per real second; at RPM 600 it fills 3x as fast.OreSpawnTimeis pulled from the selectedDeepMinablesGenerationData.GetTimeToMine()once per completed ore, set onOnRegisteredand reset after each spawn.- When
_currentProgress > OreSpawnTime, an ore is spawned as aDirtyOreprefab ("ItemDirtyOre"), populated via_deepMinables.SetValues(dirtyOre)(which determines the ore reagent based on the world region), and moved intoExportSlot. - No temperature, stress, or limiter gating on ore production: it depends only on
Rpmgoing above zero,_isReachedBedRock,OnOff,Powered,Error == 0, and the export chute being unblocked.
Practical consequence for an IC10 script: RPM is the only thing that matters for mining throughput. Keeping RPM high (targeting the ~600 RPM steady-state at temperature ~5000 K, or higher if limiter/throttle keep stress contained) directly multiplies ore output. Temperature and limiter matter only to the extent they feed the RPM loop in InternalCombustion.SpeedTick.
Gas output and venting¶
// InternalCombustion.HandleGasOutput
public void HandleGasOutput(PressurekPa pressurePerTick, PipeNetwork inputNetwork, PipeNetwork outputNetwork)
{
PressurekPa pressureGassesAndLiquids = Parent.InternalAtmosphere.PressureGassesAndLiquids;
if (!(pressureGassesAndLiquids <= _targetPressure))
{
PressurekPa pressure = pressureGassesAndLiquids - _targetPressure;
MoleQuantity transferMoles = RocketMath.Min(
IdealGas.Quantity(pressurePerTick, Chemistry.PipeVolume, inputNetwork.Atmosphere.Temperature),
IdealGas.Quantity(pressure, Parent.InternalAtmosphere.Volume, Parent.InternalAtmosphere.Temperature) * 0.1
);
GasMixture gasMixture = Parent.InternalAtmosphere.Remove(transferMoles, AtmosphereHelper.MatterState.All);
outputNetwork.Atmosphere.Add(gasMixture);
}
}
pressurePerTick comes from the inherited DeviceAtmospherics.PressurePerTick property:
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeviceAtmospherics
[FormerlySerializedAs("PressurePerTick")]
[SerializeField]
protected float pressurePerTick = 101.325f;
public PressurekPa PressurePerTick => new PressurekPa(pressurePerTick);
The serialized default is 101.325 kPa per atmospheric tick (1 atm/tick). This caps the first argument of the RocketMath.Min in HandleGasOutput: the vent can transfer at most the moles corresponding to 101.325 kPa in a Chemistry.PipeVolume at the input pipe's temperature, OR 10% of the chamber's overpressure, whichever is smaller. At 4000 kPa chamber / 5000 K and pipe at ~300 K / 10 L, the 10% overpressure term is the usual binder; the pressurePerTick term only starts dominating at severe overpressures or when the input pipe is very cold.
Note the quirk: pressurePerTick is looked up against the input network's Atmosphere.Temperature and against Chemistry.PipeVolume, not the chamber. That is a game-wide DeviceAtmospherics convention: the "how many moles can a device move per tick" budget is always computed as though the transfer were happening in a reference pipe volume at the input pipe's temperature, regardless of the actual source or sink.
_targetPressure resolves to RunningTargetPressure while running, defined verbatim as:
// InternalCombustion class field
private static readonly PressurekPa RunningTargetPressure = new PressurekPa(4000.0);
PressurekPa is a struct wrapper around a raw double _value (1.0 in PressurekPa space = 1 kPa; no scaling factor), so the target is 4000 kPa exactly. No sibling constants (StartingTargetPressure, IdleTargetPressure, etc.) exist; this single value governs every running-state venting decision.
During shutdown, the target slides down proportional to RPM:
// InternalCombustion.HandleShutDown
_targetPressure = RocketMath.Lerp(PressurekPa.Zero, RunningTargetPressure, Mathf.Clamp01(Rpm / 1000f));
Above the target, up to 10% of the overpressure vents per tick (bounded by pipe transfer capacity). No damage or efficiency penalty for venting; it is pure loss of unburned mixture into the output pipe.
Logic variables¶
// CombustionDeepMiner.CanLogicRead / CanLogicWrite / GetLogicValue / SetLogicValue
public override bool CanLogicRead(LogicType logicType)
{
switch (logicType) {
case LogicType.CombustionLimiter: return true;
case LogicType.Throttle: return true;
case LogicType.Rpm: return true;
case LogicType.Stress: return true;
case LogicType.Setting:
case LogicType.Maximum:
case LogicType.Ratio: return false;
default: return base.CanLogicRead(logicType);
}
}
public override bool CanLogicWrite(LogicType logicType) => logicType switch {
LogicType.CombustionLimiter => true,
LogicType.Throttle => true,
LogicType.Setting => false,
_ => base.CanLogicWrite(logicType),
};
public override double GetLogicValue(LogicType logicType) => logicType switch {
LogicType.CombustionLimiter => _internalCombustion.CombustionLimiter,
LogicType.Throttle => _internalCombustion.Throttle,
LogicType.Rpm => _internalCombustion.Rpm,
LogicType.Stress => _internalCombustion.Stress,
_ => base.GetLogicValue(logicType),
};
| LogicType | Read | Write | Source | Notes |
|---|---|---|---|---|
Throttle |
yes | yes | CombustionDeepMiner |
Clamped 0..100, snaps to mult of 10. Writes via _internalCombustion.Throttle = (float)value. |
CombustionLimiter |
yes | yes | CombustionDeepMiner |
Clamped 0..100, snaps to mult of 10. Writes via _internalCombustion.CombustionLimiter = (float)value. |
Rpm |
yes | no | CombustionDeepMiner |
Current rotations/min. Read-only. |
Stress |
yes | no | CombustionDeepMiner |
0..100. Read-only. Synchronised on network-update flag 32768. |
Setting, Maximum, Ratio |
no | no | CombustionDeepMiner (blocked) |
Explicitly blocked in CanLogicRead / CanLogicWrite. |
Temperature |
yes | no | Device.GetLogicValue via HasReadableAtmosphere |
Returns InternalAtmosphere.Temperature.ToDouble() (chamber, not input pipe). Kelvin. |
Pressure |
yes | no | Device.GetLogicValue via HasReadableAtmosphere |
Returns InternalAtmosphere.PressureGassesAndLiquids.ToDouble() (chamber). kPa. |
TotalMoles |
yes | no | Device.GetLogicValue via HasReadableAtmosphere |
Returns InternalAtmosphere.TotalMoles.ToDouble(). |
RatioXxx (Oxygen, Hydrogen, Steam, Pollutant, CarbonDioxide, etc.) |
yes | no | Device.GetLogicValue via HasReadableAtmosphere |
Chamber ratios. |
RatioXxxInput, PressureInput, TemperatureInput, TotalMolesInput |
yes | no | DeviceInputOutput |
Input pipe network readings. Similar ...Input2, ...Output, ...Output2 also exposed. |
CombustionInput |
yes | no | DeviceInputOutput |
Returns InputNetwork.Atmosphere.Inflamed ? 1 : 0. Boolean flame-present flag on the input pipe, not fuel energy / potency / enthalpy. 0 for cold unburned mix, 1 only while the input pipe's gas is actively burning (rare in a fuel-feed pipe). Also available as CombustionInput2, CombustionOutput, CombustionOutput2. |
On |
yes | yes | Device via HasOnOffState |
Writable; routes to OnServer.Interact(base.InteractOnOff, state). |
Power, Error, RequiredPower, Activate, Open, Mode, Lock |
inherited | inherited (some writable) | Device / Logicable |
Standard device plumbing. |
Concretely for the script's read set:
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.Device.GetLogicValue (relevant cases)
case LogicType.On: return OnOff ? 1 : 0;
case LogicType.TotalMoles: return base.InternalAtmosphere?.TotalMoles.ToDouble() ?? 0.0;
case LogicType.Pressure: return base.InternalAtmosphere?.PressureGassesAndLiquids.ToDouble() ?? 0.0;
case LogicType.Temperature: return base.InternalAtmosphere?.Temperature.ToDouble() ?? 0.0;
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.Device.CanLogicRead (relevant cases)
case LogicType.On: return HasOnOffState;
case LogicType.Pressure:
case LogicType.Temperature:
case LogicType.RatioOxygen:
... (all ratio / moles / pressure / temperature cases):
return HasReadableAtmosphere;
CombustionDeepMiner.HasReadableAtmosphere => true (verbatim override), so every ratio-style read above resolves to the chamber's internal atmosphere rather than returning zero or falling through to the base false.
Fuel chemistry: Combust method¶
The deep miner routes through GasMixture.TryCombust -> GasMixture.Combust -> fuel-specific CombustionResult.RunCombustion. The combustion table uses CombustionResult records and fuel enthalpies.
// Assets.Scripts.Atmospherics.Combustion (reaction stoichiometry + output gases)
public static readonly CombustionResult ResultHydrogenOxygen = new CombustionResult(
fuelCount: 2.0, oxidiserCount: 1.0,
outputs: new[] { new CombustionValue(Chemistry.GasType.Steam, 3.0) });
public static readonly CombustionResult ResultHydrogenOzone = new CombustionResult(
fuelCount: 3.0, oxidiserCount: 1.0,
outputs: new[] { new CombustionValue(Chemistry.GasType.Steam, 4.0) });
public static readonly CombustionResult ResultMethaneOxygen = new CombustionResult(
fuelCount: 2.0, oxidiserCount: 1.0,
outputs: new[] {
new CombustionValue(Chemistry.GasType.Pollutant, 3.0),
new CombustionValue(Chemistry.GasType.CarbonDioxide, 6.0) });
public static readonly CombustionResult ResultMethaneOzone = new CombustionResult(
fuelCount: 3.0, oxidiserCount: 2.0,
outputs: new[] {
new CombustionValue(Chemistry.GasType.Pollutant, 3.0),
new CombustionValue(Chemistry.GasType.CarbonDioxide, 6.0),
new CombustionValue(Chemistry.GasType.Steam, 1.0) });
// Assets.Scripts.Atmospherics.Mole.Enthalpy (fuel side)
public static double Enthalpy(Chemistry.GasType gasType) => gasType switch {
Chemistry.GasType.Methane => 286000.0,
Chemistry.GasType.LiquidMethane => 286000.0,
Chemistry.GasType.Hydrogen => 306000.0,
Chemistry.GasType.LiquidHydrogen => 306000.0,
Chemistry.GasType.Hydrazine => 306000.0,
Chemistry.GasType.LiquidHydrazine => 306000.0,
Chemistry.GasType.LiquidAlcohol => 566000.0,
_ => 0.0,
};
// Oxidiser enthalpy multiplier (applied to fuel enthalpy, not an oxidiser base energy)
public double EnthalpyMultiplier => Type switch {
Chemistry.GasType.Oxygen => 1.0,
Chemistry.GasType.LiquidOxygen => 1.0,
Chemistry.GasType.NitrousOxide => 2.0,
Chemistry.GasType.LiquidNitrousOxide => 2.0,
Chemistry.GasType.Ozone => 2.0,
Chemistry.GasType.LiquidOzone => 2.0,
_ => 1.0,
};
Energy release per full reaction (stoichiometric):
| Reaction | Fuel moles | Oxidiser moles | Energy (J) | Energy / mol fuel | Energy / mol oxidiser |
|---|---|---|---|---|---|
| H2 + O2 -> steam | 2 | 1 | 612,000 | 306,000 | 612,000 |
| H2 + O3 -> steam | 3 | 1 | 918,000 | 306,000 | 918,000 |
| CH4 + O2 | 2 | 1 | 572,000 | 286,000 | 572,000 |
| CH4 + O3 | 3 | 2 | 858,000 | 286,000 | 429,000 |
Is H2 + O3 better than CH4 + O2?¶
Yes, in the deep miner specifically. Three reasons, all code-backed:
- Higher energy per mole of fuel. Hydrogen enthalpy is 306 kJ/mol vs methane 286 kJ/mol. Since the chamber's fuel intake budget is capped by moles (
MaxMolarInput = 0.01 mol/tick), every mole of hydrogen admitted releases more energy than every mole of methane. - Higher energy per mole of oxidiser. Ozone's 2.0x
EnthalpyMultiplier, combined with the 3:1 fuel:oxidiser stoichiometry, pushes energy per O3 mole to 918 kJ (vs 612 kJ per O2 mole for H2+O2). Ozone starvation is easier to avoid because each ozone mole releases more energy. - Clean exhaust. H2+O3 and H2+O2 both emit only
Steam. CH4 burns producePollutantandCarbonDioxidein large mole counts, which (because the chamber is 2 L and vents at 4000 kPa) crowd the chamber and vent into the output pipe. Steam is the only vanilla output of an H2 burn, and the mole count (3 or 4) is only slightly above the fuel count (2 or 3), so the chamber fills less quickly.
Code does not model unburned-fuel penalties, rich/lean mixture penalties, incomplete combustion, or NOx. The only "efficiency" terms are (a) total combustion energy released per tick (determines RPM accel), (b) stress (determines whether RPM gets debited), and © venting above 4000 kPa (determines whether admitted fuel is wasted).
Caveat: the pipe network delivers whatever the player puts on the input side. The deep miner does not force a stoichiometry; a 3:1 H2:O3 input mix is the player's responsibility, built upstream with a mixer / furnace / chemistry output. Off-ratio mixes just leave leftover H2 or O3 in the chamber, which vents unburned at 4000 kPa.
Constants summary¶
| Name | Value | Location |
|---|---|---|
| Throttle clamp | [0, 100] step 10 | InternalCombustion.Throttle setter |
| CombustionLimiter clamp | [0, 100] step 10 | InternalCombustion.CombustionLimiter setter |
| Max molar input | 0.009999999776482582 mol/tick | InternalCombustion.MaxMolarInput |
| Burn fraction formula | Clamp(Pow(CL/10, 2) * 0.0075, 0.001, 1) |
InternalCombustion.ManualCombust |
| Chamber volume | 2.0 L | CombustionDeepMiner.InitInternalAtmosphere |
| Ignition temperature | 573.15 K (300 C) | InternalCombustion.SpeedTick |
| Energy threshold | 30 J (MoleEnergy(30.0) stored verbatim; MoleEnergy wraps a raw double _value with no unit multiplier, and CombustionEnergy accumulates J via Mole.Enthalpy() * EnthalpyMultiplier * Quantity.ToDouble()) |
InternalCombustion.SpeedTick |
| RPM accel coefficient | Lerp(0, 12, T / 5000 K) |
InternalCombustion.SpeedTick |
| Friction per tick | 0.99 (1% drag) | InternalCombustion.SpeedTick |
| Stress hysteresis factor | Lerp(0.8, 2, (Rpm - 300) / 1200) |
InternalCombustion.SpeedTick |
| Stress failure | Stress >= 100 -> -40 RPM + Error=1 | InternalCombustion.SpeedTick |
| Stress decay | -0.5 / tick when not tripped | InternalCombustion.SpeedTick |
| Shutdown RPM decay | -15 per tick while !_gainedStress |
InternalCombustion.HandleShutDown |
| Running target pressure | 4000 kPa | InternalCombustion.RunningTargetPressure |
| Vent rate (chamber side) | up to 10% of overpressure / tick | InternalCombustion.HandleGasOutput |
| Vent rate (pipe side) | IdealGas.Quantity(101.325 kPa, PipeVolume, InputTemp) |
DeviceAtmospherics.pressurePerTick default |
| STANDARD_RPM | 200f | DeepMiner.STANDARD_RPM |
| DRILL_DOWN_SPEED | 0.125 m/s at RPM 200 | DeepMiner.DRILL_DOWN_SPEED |
| RpmNormalised | rpm / 200f |
DeepMiner.RpmNormalised |
| Stress audio thresholds | warn at 40, loud band 50..95, max-stress at 95 | CombustionDeepMiner.HandleStressAnimSound |
| Dial wobble thresholds | STRESS_WOBBLE_THRESHOLD=40, WOBBLE_RANGE=60 |
CombustionDeepMiner consts |
| Atmospheric tick rate | 20 Hz (0.05 s) | Game-wide atmosphere sim |
| H2 enthalpy | 306,000 J/mol | Mole.Enthalpy |
| CH4 enthalpy | 286,000 J/mol | Mole.Enthalpy |
| O3 enthalpy multiplier | 2.0 | Mole.EnthalpyMultiplier |
| H2 + O2 ratio | 2:1 | Combustion.ResultHydrogenOxygen |
| H2 + O3 ratio | 3:1 | Combustion.ResultHydrogenOzone |
Verification history¶
- 2026-04-20: page created. Decompile pass against
E:\Steam\steamapps\common\Stationeers\rocketstation_Data\Managed\Assembly-CSharp.dllvia ILSpy. All section stamps set to 0.2.6228.27061. - 2026-04-20: re-verified
RunningTargetPressuredefinition. Added verbatimprivate static readonly PressurekPa RunningTargetPressure = new PressurekPa(4000.0)quote,HandleShutDownLerp quote, andPressurekPaunit convention note to the "Gas output and venting" section. No sibling target-pressure constants exist. Section stamp unchanged (same version, same day). - 2026-04-20: re-verified the step-of-10 rounding claim on both lever setters. Setter bodies unchanged from the page's existing quotes; order is
Clamp([0, 100])thenRound(value / 10f) * 10f.CombustionDeepMiner.SetLogicValueis a pass-through cast from double to float with no intermediate rounding. No additional Float16 quantization on the network-serialization path forThrottle/CombustionLimiter(both only setNetworkUpdateFlagsbits, 4096 and 8192 respectively). Unity'sMathf.Rounduses banker's rounding per Unity docs, but the governor never writes half-values so the behavior at .5 is not exercised. - 2026-04-24: added "Atmosphere / GasMixture access for chamber composition" subsection. Verbatim verified:
Atmosphere.CombustionEnergyis a public field (public MoleEnergy CombustionEnergy = MoleEnergy.Zero;) not a property; per-gas ratios are read via the methodGasMixture.GetGasTypeRatio(Chemistry.GasType)returningfloat, with no per-gas Ratio properties on eitherAtmosphereorGasMixture;InternalCombustion._normalCombustionEnergyCacheis a private MoleEnergy field on the global-namespace type, resolvable viaType.GetType("InternalCombustion, Assembly-CSharp")and snapshotsatm.CombustionEnergyonce per tick before TryCombust runs. Resolves bugs in DeepMinerLogger v0.1.0 where ratio reads and CombustionEnergy reads were returning 0 because they searched for properties that did not exist. Additive content; no contradiction with existing claims. - 2026-04-24: full-class decompile pass of
CombustionDeepMinerandInternalCombustion. Added a new "Additional miner-level findings (full-class decompile 2026-04-24)" section capturing findings not previously documented:CanMine()verbatim (two conditions:ThingInTheWay == nullAND_deepMinables != null, nothing else),CombustionDeepMiner.AssessErroroverride (mirrorsIsOperableauto-toggle on a second path, gated onGameManager.RunSimulation),CombustionDeepMinerSaveDatafour-field persistence (Throttle, CombustionLimiter, Rpm, Stress), network update flag 16384 for Rpm (completing the flag set), InteractWith Button4-7 mappings (Throttle/CombustionLimiter ± 10), motor audio silencing at RPM < 2,Awakeas the only init override, confirmation that no damage/wear state exists on the miner, and confirmation thatInputNetwork2is structurally present but never consumed byHandleGasInputor checked byIsOperable/AssessError. Flagged an ordering issue in the page: "Error = 1 trigger conditions", "HandleShutDown implementation", "Stress dial display (real-time behavior)", and "Stress audio thresholds (refined)" were appended after "Verification history" by an earlier session and should be moved above VH in a future cleanup pass, consolidating overlapping material with the "Error states and shutdown" section. No contradictions with existing verified content. New open questions: where_deepMinablesis assigned (likelyDeepMiner.OnRegistered/IGenerateMinables), whereThingInTheWayis set (likelyDeepMiner.CheckThingInWay()called fromDrillDownAnimation), whereInteractErrorbit is defined on baseDevice. - 2026-04-24: expanded "Error states and shutdown" with a dedicated "IsInputValid / IsOutputValid: structural, no pressure minimum" subsection. Verbatim decompiled
IsInputValid,IsOutputValid,IsInput2ValidfromDeviceInputOutput; confirmed no override anywhere in the chain (DeviceInputOutputImport,DeviceInputOutputImportExport,DeviceInputOutputImportExportCircuit,DeepMiner,CombustionDeepMiner). Documented the three structural checks (network not null,IsNetworkValid= StructureList count > 0, notIsAwaitingEvent), and explicitly noted what the property does NOT check: no minimum pressure, no mole count, no gas-type, no matter-state, no temperature. Flagged theIsInput2Validquirk: secondary input has its own validity property, butCombustionDeepMiner.IsOperablegates only on primaryIsInputValid/IsOutputValid, notIsInput2Valid;HandleGasInputalso consumes only from the primary. Additive content under already-stamped "Error states and shutdown" section; no contradiction. Resolves the open question "does low input pipe pressure trigger Error=1" - it does not. - 2026-04-24: added "Error states and shutdown" section with verbatim
CombustionDeepMiner.IsOperableoverride, verbatimInternalCombustion.HandleShutDown, and the complete list of sixError = 1triggers (five viaIsOperableauto-toggle, one viaSpeedTickstress branch). Previous page coverage was limited to the stress-trip case and a single "Shutdown RPM decay" row in the constants table; theIsOperableauto-toggle and the non-stress Error paths were undocumented, and the claim that stress=100 "knocks IsOperable false" in the "Per-tick rules" bullet list was incorrect (stress sets Error directly in SpeedTick; IsOperable is a separate gate over physical conditions and auto-clears Error next tick unless a physical condition also holds). Corrected the bullet, added cross-reference, added "Stress dial and audio" subsection documenting the 2/sec lerp on the needle (explains why brief stress spikes are invisible on the dial while sustained stress=100 is visible within ~1.5 s). Stress audio thresholds updated from>=to>conditions with the 90-95 hysteresis band. Additive section; no contradiction with existing verified content beyond the stress/IsOperable clarification. Added to sources:CombustionDeepMiner.IsOperable,DeviceInputOutput.IsInputValid,DeviceInputOutput.IsOutputValid,DeepMiner.CanMine,InternalCombustion.HandleShutDown. Section stamps: "SpeedTick: RPM, friction, stress" and "Error states and shutdown" at 0.2.6228.27061 @ 2026-04-24. - 2026-04-24: conflict on "SpeedTick energy gate units". Previous claim (across "SpeedTick: RPM, friction, stress", "Startup transient", and "Constants summary"):
MoleEnergy(30.0)= 30 MJ (30,000,000 J). New finding:MoleEnergy(30.0)= 30 J. Fresh validator verdict: B is correct, gate is 30 J. Verbatim evidence:public MoleEnergy(double value) { _value = value; }stores verbatim with no multiplier;combustionEnergy = new MoleEnergy(mole.Enthalpy() * mole2.EnthalpyMultiplier * mole.Quantity.ToDouble())inCombustionResult.RunCombustionwhereMole.Enthalpy(Hydrogen) => 306000.0(J/mol), soCombustionEnergyis in SI joules. Result: corrected "30 MJ" / "30 MoleEnergy" to "30 J" in all six locations, added explanatory parentheticals on theMoleEnergyunit convention, addedCombustionResultandMoleEnergyto sources. Practical consequence: the gate is trivially clearable (0.0001 mol of H2 combustion clears it), so it effectively only closes when the chamber is empty of fuel. This weakens the "Throttle=0 shuts combustion in N seconds" argument in "Startup transient" only quantitatively (the gate does eventually close but via chamber draining, not via the gate being tight). Sections restamped: SpeedTick, Startup transient, Constants summary. - 2026-04-24: resolved
CombustionInputsemantics in "Logic variables". DecompiledAssets.Scripts.Objects.Pipes.DeviceInputOutput.GetLogicValue(and siblingDeviceInput.GetLogicValue) againstE:\Steam\steamapps\common\Stationeers\rocketstation_Data\Managed\Assembly-CSharp.dllvia ilspycmd. Both implementations readCombustionInputasInputNetwork.Atmosphere.Inflamed ? 1 : 0, a boolean flame-present flag. This is not a fuel potency / enthalpy / combustion-energy reading; it is true only while the input pipe's gas mixture is actively burning (set insideAtmosphere.TryCombust, cleared byAtmosphere.ResetCombustionData). Corrected the table entry previously grouped with the quantitative input reads. Section stamp on "Logic variables" bumped to 2026-04-24. - 2026-04-22: clarified the num Lerp clamping in "SpeedTick: RPM, friction, stress".
Mathf.Clamp01is on the lerp fractionT/5000, not on T itself; chamber temperature can rise arbitrarily above 5000 K while num saturates at 12. No code change; the verbatimSpeedTickquote already contains this, the sentence summarising it was ambiguous ("maxing at 12 RPM/tick near 5000 K" read as if T were also capped). Reinforces the "Temperature above 5000 K is wasted fuel" intuition used by the control algorithm. Section stamp unchanged (same day). - 2026-04-22: corrected steady-state RPM formula and mismatch formula in "SpeedTick: RPM, friction, stress". Previous text said
Rpm_steady = 100 * numandmismatch = |num - 0.01*Rpm|. Re-derivation from the verbatimSpeedTickcode (num2 = Rpm + num,num4 = num2 * 0.99,Rpm = num4,num5 = num2 - num4,num6 = |num - num5|) givesRpm_ss = 99 * numandmismatch = |0.99*num - 0.01*Rpm|. Max RPM at num=12 is 1188, not 1200. No decompile; sign-error in the algebra on the existing code quote. Section stamp on "SpeedTick: RPM, friction, stress" bumped to 2026-04-22. Downstream sections ("Startup transient" tables and values) use the slightly-off formulas; differences are ~1% in stress-per-tick values and unchanged qualitative conclusions, so those sections are not restamped pending a fuller pass. - 2026-04-21: added "Startup transient: why a hot chamber explodes on any throttle" section. Derived analysis from existing
SpeedTickquotes in "SpeedTick: RPM, friction, stress"; no new decompile. Documents the temperature-only dependence ofnum, the stress/sec table as a function of chamber T, whyCombustionLimiterandThrottle = 10cannot stop a hot-chamber runaway, whyThrottle = 0takes multiple seconds to close the combustion gates, the mechanics that make the user's manual pulse strategy work, the safe cold-start regime, and governor implications for IC10 scripts that must start on a hot chamber. - 2026-04-21: re-decompiled
CombustionDeepMiner,DeepMiner,InternalCombustion,DeviceInputOutput,DeviceAtmospherics,Pipes.Device,IInternalCombustionagainst the same DLL. Extended the "Class hierarchy" section with the full chain (CombustionDeepMiner->DeepMiner->DeviceInputOutputImportExportCircuit->DeviceInputOutputImportExport->DeviceInputOutputImport->DeviceInputOutput->DeviceAtmospherics->Device) and the per-class ownership of state. Corrected theInitInternalAtmospheresource: it is aCombustionDeepMineroverride with its ownprivate static readonly VolumeLitres Volume = new VolumeLitres(2.0)field, not inherited fromDeepMiner. Added "Drill depth and ore yield" section with verbatimDeepMiner.DrillDownAnimation,ProgressProcessing,IsSpawnTime,ResetSpawnTime,SpawnOre,RpmNormalisedquotes; resolves the prior open question. Extended "Gas output and venting" with theDeviceAtmospherics.pressurePerTick = 101.325fdefault and the pipe-side vent-rate cap. Extended "Tick pipeline" withHandleGasInputgating onError <= 0and the RPM half-life calculation for lag after a limiter/throttle change. Extended "Logic variables" with explicit rows forTemperature,Pressure,TotalMoles,RatioXxx,On, and a verbatim excerpt ofDevice.CanLogicRead/Device.GetLogicValueshowing thatHasReadableAtmosphere => trueonCombustionDeepMinerroutes those reads to the chamber's internal atmosphere. AddedSTANDARD_RPM,DRILL_DOWN_SPEED,RpmNormalised, shutdown RPM decay, pipe-side vent rate, stress-audio thresholds, and dial-wobble constants to the "Constants summary" table.
Open questions¶
- Liquid fuels in a combustion chamber:
Mole.Enthalpyreturns the same energy forHydrogenandLiquidHydrogen, but theCombustmethod subtracts a latent-heat term (IdealGas.Energy(burned_fuel_moles, fuel.LatentHeat)) when the fuel matter-state is liquid. Net effect on deep-miner RPM accel was not measured in this pass.
Error = 1 trigger conditions¶
Every path that sets Error = 1 on CombustionDeepMiner:
1. Stress reaches 100 (SpeedTick) — requires stress to hit 100:
// InternalCombustion.SpeedTick
if (Stress >= 100f)
{
Rpm -= 40f;
if (Parent.GetAsThing.Error == 0)
{
OnServer.Interact(Parent.GetAsThing.InteractError, 1);
}
}
2. IsOperable property returns false — does NOT require stress to hit 100. Triggered when any of these conditions are true:
// CombustionDeepMiner.IsOperable (property getter)
bool flag = (bool)base.ProgrammableChip && (CodeErrorState != 0 || base.ProgrammableChip.CompilationError);
bool flag2 = base.IsInputValid && base.IsOutputValid && !flag && CanMine() && base.IsStructureCompleted;
if (Error == 0 && !flag2)
{
OnServer.Interact(base.InteractError, 1);
}
This sets Error = 1 when ANY of these are true:
- !base.IsInputValid: input pipe disconnected or broken
- !base.IsOutputValid: output pipe disconnected or broken
- CodeErrorState != 0: ProgrammableChip has a runtime error
- base.ProgrammableChip.CompilationError: ProgrammableChip failed to compile
- !CanMine(): either ThingInTheWay != null OR _deepMinables == null
- !base.IsStructureCompleted: structure blueprint not complete
3. OnAtmosphericTick immediately calls HandleShutDown when Error=1 — this is a consequence, not a trigger:
// CombustionDeepMiner.OnAtmosphericTick (line 226 in decompile)
if (!OnOff || !Powered || Error == 1)
{
_internalCombustion.HandleShutDown();
}
4. AssessError (called from DeepMiner base class) — duplicate of IsOperable logic:
// CombustionDeepMiner.AssessError override
public override void AssessError()
{
if (GameManager.RunSimulation)
{
bool flag = (bool)base.ProgrammableChip && (CodeErrorState != 0 || base.ProgrammableChip.CompilationError);
bool flag2 = base.IsInputValid && base.IsOutputValid && !flag && CanMine() && base.IsStructureCompleted;
if (Error == 0 && !flag2)
{
OnServer.Interact(base.InteractError, 1);
}
else if (Error == 1 && flag2)
{
OnServer.Interact(base.InteractError, 0);
}
}
}
HandleShutDown implementation¶
// InternalCombustion.HandleShutDown
public void HandleShutDown()
{
if (!_gainedStress)
{
Rpm -= 15f;
_targetPressure = RocketMath.Lerp(PressurekPa.Zero, RunningTargetPressure, Mathf.Clamp01(Rpm / 1000f));
}
}
Behavior when shutdown is active:
- If _gainedStress is false: decays RPM by 15 per tick, and _targetPressure slides from 4000 kPa down to 0 as RPM falls toward 0.
- Stress continues to decay at 0.5/tick in SpeedTick (handled before the shutdown branch).
- Stress does NOT freeze; it decays automatically when the mismatch condition clears.
- Chamber continues to vent via HandleGasOutput (line 228 in OnAtmosphericTick, which runs AFTER the HandleShutDown call).
- Chamber does NOT admit new gas: HandleGasInput (line 229) is gated on Error <= 0, so when Error = 1, no fuel enters.
Consequence: when a miner crashes due to any non-stress Error = 1, the chamber fuel load drains at whatever burn-fraction rate CombustionLimiter specifies (minimum 0.1%, maximum 75%), while RPM decays by 15/tick (4.3x faster than the running friction-only decay of 1%/tick) and venting runs freely.
Stress dial display (real-time behavior)¶
The stress dial is smoothed with Lerp, NOT driven by live instantaneous stress value:
// Dial.UpdatePosition (called from CombustionDeepMiner.UpdateEachFrame every frame)
public void UpdatePosition(float target, float deltaTime)
{
float t = Mathf.Clamp01(target / maxValue);
float b = Mathf.Lerp(needleMinimum, needleMaximum, t);
_lastAngle = Mathf.Lerp(_lastAngle, b, deltaTime * lerpSpeed);
_needleTransform.localRotation = Quaternion.AngleAxis(_lastAngle, axisOfRotation);
}
lerpSpeed = 2f(hardcoded constant in Dial.cs).- Each frame:
_lastAnglemoves toward the target angle at rateMathf.Lerp(..., deltaTime * 2.0f). - At 60 FPS (deltaTime ≈ 0.0167 s): dial moves
0.0167 * 2 = 0.0334 = 3.34%of remaining distance per frame. - Time to reach 63% of target: ~1 frame at 60 FPS.
- Time to reach 95% of target: ~89 frames ≈ 1.48 seconds.
Consequence: A stress spike from 0 to 100 that occurs in a single 0.05 s atmospheric tick (while the dial updates at 60 FPS in the render loop) would: - Update the dial once per frame during the spike. - On the frame when stress = 100: dial angle = Lerp(previous, 100_degrees, 0.0167 * 2) ≈ 3.3% of the way to full scale. - Next frame, stress is already decaying (0.5/tick in SpeedTick). - Dial would show a barely-visible upward twitch, then slowly creep downward.
The player's observation that "stress dial stays low the whole time... never visibly climbs" is consistent with stress spiking above 100 and immediately crashing the miner, leaving the dial showing only the low mid-decay remainder before shutdown fully kicks in.
Stress audio thresholds (refined)¶
// CombustionDeepMiner.HandleStressAnimSound
if (_internalCombustion.Stress > 40f)
{
float t = Mathf.Clamp01((_internalCombustion.Stress - 50f) / 50f);
num = Mathf.Lerp(0.3f, 1f, t);
// ... trigger / update stress audio
}
else if (_stressAudio.IsPlaying)
{
_stressAudio.Stop();
}
if (_internalCombustion.Stress < 90f && _maxStressAudio.IsPlaying)
{
_maxStressAudio.Stop();
}
else if (_internalCombustion.Stress > 95f && !_maxStressAudio.IsPlaying)
{
_maxStressAudio.Trigger();
}
- Warning audio (steady hum): triggers when
Stress > 40f, volume ramps from 0.3 to 1.0 as stress goes from 50 to 100. - Max-stress audio (loud alarm): triggers when
Stress > 95f, stops whenStress < 90f(hysteresis band).
The page's summary of "warn at 40, loud band 50..95, max-stress at 95" is slightly loose; the precise conditions are Stress > 40 for warn-trigger and Stress > 95 for max-stress.
Additional miner-level findings (full-class decompile 2026-04-24)¶
This section captures findings from a whole-class decompile pass of CombustionDeepMiner and InternalCombustion that are not covered by the sections above. Note: the ordering of this page is currently inconsistent because earlier sessions appended content after ## Verification history; a future pass should move "Error = 1 trigger conditions", "HandleShutDown implementation", "Stress dial display (real-time behavior)", "Stress audio thresholds (refined)", and this section up into their logical positions before Verification History, and consolidate overlapping material with the earlier "Error states and shutdown" section.
CanMine verbatim¶
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.DeepMiner.CanMine
public bool CanMine()
{
if (!ThingInTheWay)
{
return _deepMinables != null;
}
return false;
}
Two conditions only: no physical obstruction (ThingInTheWay == null) AND an ore-generation table is set (_deepMinables != null). No export-chute check, no bedrock check, no silo-fill check, no power check (power is gated separately via Powered in OnAtmosphericTick's second shutdown branch). A mining miner that stops because !CanMine() went false is either obstructed or lost its minable table; those are the only two levers.
_deepMinables is assigned once per world region from WorldSetting.Current.Data.DeepMinablesData on registration; the exact assignment site was not re-verified in this pass. ThingInTheWay is checked in DeepMiner.CheckThingInWay() (also not re-verified) and referenced from DrillDownAnimation. Both remain as open questions for a targeted decompile.
AssessError is a second path that mirrors IsOperable¶
// Assembly-CSharp.dll :: Assets.Scripts.Objects.Pipes.CombustionDeepMiner.AssessError
public override void AssessError()
{
if (GameManager.RunSimulation)
{
bool flag = (bool)base.ProgrammableChip && (CodeErrorState != 0 || base.ProgrammableChip.CompilationError);
bool flag2 = base.IsInputValid && base.IsOutputValid && !flag && CanMine() && base.IsStructureCompleted;
if (Error == 0 && !flag2)
OnServer.Interact(base.InteractError, 1);
else if (Error == 1 && flag2)
OnServer.Interact(base.InteractError, 0);
}
}
AssessError is a base-class-driven hook that duplicates the IsOperable auto-toggle logic verbatim, gated on GameManager.RunSimulation. This means Error auto-set/clear fires from two paths per tick (once from IsOperable in OnAtmosphericTick, once from AssessError in whatever base tick calls it). Net behaviour is idempotent because both sides apply the same branch conditions; the redundancy matters only as noise in the Interact history.
CombustionDeepMinerSaveData persists four fields¶
public class CombustionDeepMinerSaveData : DeepMinerSaveData
{
public float Throttle;
public float CombustionLimiter;
public float Rpm;
public float Stress;
}
InitialiseSaveData writes these four; DeserializeSave reads them back in the same order. SerializeOnJoin / DeserializeOnJoin transmit the same four on network join, same order. No chamber-atmosphere persistence sits on CombustionDeepMiner specifically; whatever DeviceAtmospherics saves for InternalAtmosphere applies through the base path.
Saved -> loaded invariants: Throttle, CombustionLimiter, Rpm, Stress all survive save/load and rejoin. A reloaded miner resumes at its persisted RPM and stress; it does not cold-start.
Network update flags: RPM uses 16384¶
Completing the flag set previously documented (4096 Throttle, 8192 CombustionLimiter, 32768 Stress):
| Bit | Field | Setter trigger |
|---|---|---|
| 4096 | Throttle | Throttle setter |
| 8192 | CombustionLimiter | CombustionLimiter setter |
| 16384 | Rpm | Rpm setter on the server |
| 32768 | Stress | Stress setter on the server |
No other flags. No custom RPCs. ProcessUpdate applies the four fields directly to _internalCombustion on the client side; no interpolation, no prediction. The dial's visual lerp (rate 2/sec) is the only smoothing anywhere in the stack.
Player interaction buttons route through the same setters as logic¶
// CombustionDeepMiner.InteractWith (button4-7 only)
case Button4: _internalCombustion.Throttle = Mathf.Clamp(_internalCombustion.Throttle - 10f, 0f, 100f); break;
case Button5: _internalCombustion.Throttle = Mathf.Clamp(_internalCombustion.Throttle + 10f, 0f, 100f); break;
case Button6: _internalCombustion.CombustionLimiter = Mathf.Clamp(_internalCombustion.CombustionLimiter - 10f, 0f, 100f); break;
case Button7: _internalCombustion.CombustionLimiter = Mathf.Clamp(_internalCombustion.CombustionLimiter + 10f, 0f, 100f); break;
Matters: manual button interactions and IC10 sb ... Throttle writes hit the same property setter. Lever animation, step-of-10 rounding, and network-flag emission fire identically regardless of path.
Motor sound gated on RPM >= 2¶
// CombustionDeepMiner sound update
if (IsOccluded || Rpm < 2f || !base.IsStructureCompleted || !OnOff || !Powered)
{
_motorAudio.UpdatePlayState(shouldPlay: false);
}
The motor audio silences at RPM < 2. Low relevance for gameplay but useful when debugging - "the miner is silent" does not imply "the miner is off"; it can also mean RPM decayed to < 2 via friction.
Awake is the only init override; _internalCombustion is constructed here¶
public override void Awake()
{
base.Awake();
if (!IsCursor)
{
_internalCombustion = new InternalCombustion(this);
}
dial?.Init();
_gear1Audio = GetAudioEvent(Animator.StringToHash("GearTwo"));
_gear3Audio = GetAudioEvent(Animator.StringToHash("GearFour"));
_stressAudio = GetAudioEvent(Animator.StringToHash("Stress"));
_maxStressAudio = GetAudioEvent(Animator.StringToHash("MaxStress"));
_motorAudio = GetAudioEvent(Animator.StringToHash("MotorRunning"));
}
No OnFinishedLoad, OnRegistered, or Start override on CombustionDeepMiner. _internalCombustion must exist before any atmospheric tick runs; the IsCursor guard prevents the cursor preview prefab from constructing one.
No damage, wear, or decay on the miner itself¶
No OnDamage, TakeDamage, DamageState override, or wear-state field on CombustionDeepMiner or DeepMiner. The only degradation mechanism is RPM loss (friction, stress trip, shutdown). Save/load persistence carries Throttle, CombustionLimiter, Rpm, Stress only; no implicit "miner got damaged after 10 hours of runtime" state. A miner that behaves differently today than yesterday is not doing so because of accumulated wear.
Atmosphere / GasMixture access for chamber composition¶
The fields and methods needed to read the chamber's per-gas composition and combustion-energy potential, verified verbatim:
// Assets.Scripts.Atmospherics.Atmosphere
public MoleEnergy CombustionEnergy = MoleEnergy.Zero; // PUBLIC FIELD, not a property
Atmosphere.CombustionEnergy is a public field, initialized to MoleEnergy.Zero. This is the value the SpeedTick gate compares against the 30 J floor (Parent.InternalAtmosphere.CombustionEnergy + _normalCombustionEnergyCache >= moleEnergy). Reading it requires Type.GetField, not Type.GetProperty.
// Assets.Scripts.Atmospherics.GasMixture
public float GetGasTypeRatio(Chemistry.GasType gasType)
{
MoleQuantity getTotalMolesGassesAndLiquids = GetTotalMolesGassesAndLiquids;
if (getTotalMolesGassesAndLiquids.Equals(MoleQuantity.Zero))
return 0f;
return gasType switch
{
Chemistry.GasType.Oxygen => (Oxygen.Quantity / getTotalMolesGassesAndLiquids).ToFloat(),
Chemistry.GasType.Nitrogen => (Nitrogen.Quantity / getTotalMolesGassesAndLiquids).ToFloat(),
Chemistry.GasType.Hydrogen => (Hydrogen.Quantity / getTotalMolesGassesAndLiquids).ToFloat(),
Chemistry.GasType.Steam => (Steam.Quantity / getTotalMolesGassesAndLiquids).ToFloat(),
Chemistry.GasType.CarbonDioxide => (CarbonDioxide.Quantity / getTotalMolesGassesAndLiquids).ToFloat(),
Chemistry.GasType.NitrousOxide => (NitrousOxide.Quantity / getTotalMolesGassesAndLiquids).ToFloat(),
Chemistry.GasType.Pollutant => (Pollutant.Quantity / getTotalMolesGassesAndLiquids).ToFloat(),
Chemistry.GasType.Ozone => (Ozone.Quantity / getTotalMolesGassesAndLiquids).ToFloat(),
// ...
};
}
Per-gas ratios are read via the GasMixture.GetGasTypeRatio(Chemistry.GasType) method on the chamber's GasMixture (accessed as atm.GasMixture). Returns float in [0, 1]. Returns 0 when the chamber has no gas. There are no per-gas Ratio properties on either Atmosphere or GasMixture (no Atmosphere.RatioOxygen, no GasMixture.RatioOxygen); earlier prose that named them as if they were properties was incorrect.
// InternalCombustion (default namespace, in Assembly-CSharp)
private MoleEnergy _normalCombustionEnergyCache = MoleEnergy.Zero;
The cache is a private MoleEnergy field on the global-namespace InternalCombustion type. To resolve via reflection from a mod assembly: Type.GetType("InternalCombustion, Assembly-CSharp"), then GetField with BindingFlags.Instance | BindingFlags.NonPublic. Harmony.AccessTools.TypeByName("InternalCombustion") also works since it scans loaded assemblies.
Set exactly once per OnAtmosphericTick, inside ManualCombust, before TryCombust runs:
public void ManualCombust()
{
_normalCombustionEnergyCache = Parent.InternalAtmosphere.CombustionEnergy;
Parent.InternalAtmosphere.TryCombust(...);
}
So the cache holds the pre-burn CombustionEnergy snapshot. The SpeedTick gate then sums CombustionEnergy (which TryCombust may have updated to the just-released energy) plus the cache (pre-burn potential), so the gate is effectively "did the chamber have meaningful fuel before this tick OR did this tick's burn release meaningful energy."
InputNetwork2 is structurally present but functionally dead for this device¶
DeviceInputOutput defines InputNetwork2 and the matching IsInput2Valid property. CombustionDeepMiner never reads either: OnAtmosphericTick passes only InputNetwork (not InputNetwork2) to HandleGasInput, and HandleGasInput consumes only its single parameter. The IsOperable / AssessError gates check IsInputValid (primary only), not IsInput2Valid. Consistent with the in-game observation that the miner has a single input pipe connection: the second input exists on the inheritance chain but is inert for combustion logic. Any mod that adds a second-input behaviour to this device would need to wire it explicitly.