Heat emission to atmosphere¶
How vanilla devices and game systems deposit thermal energy (joules) into atmospheres, what API to call, where to call it from, and the threading and multiplayer-authority contract a mod must respect to do the same without desync.
Canonical API: GasMixture.AddEnergy(MoleEnergy)¶
GasMixture carries the stored internal energy. There is no Atmosphere.AddHeat or Atmosphere.AddEnergyToContents wrapper; the canonical method is on the gas mixture itself. Both the struct-form and the class-form are present in the assembly (the codebase contains two AddEnergy definitions, one inside a class with private MoleEnergy _energy; and one operating on TotalEnergy); both share the same null/NaN/zero guard.
Verbatim, from Assembly-CSharp.dll decompile line 358929:
public void AddEnergy(MoleEnergy energy)
{
if (!energy.IsDenormalOrZero() && !energy.IsNaN())
{
_energy += energy;
}
}
public MoleEnergy RemoveEnergy(MoleEnergy energy)
{
if (energy <= MoleEnergy.Zero || energy.IsNaN())
{
return MoleEnergy.Zero;
}
MoleEnergy moleEnergy = RocketMath.Min(Energy, energy);
_energy -= moleEnergy;
return moleEnergy;
}
And the parallel definition (line 421702) checks emptiness in addition:
public void AddEnergy(MoleEnergy energy)
{
if (!(GetTotalMolesGassesAndLiquids <= Chemistry.MINIMUM_VALID_TOTAL_MOLES)
&& !energy.IsDenormalOrZero()
&& !energy.IsNaN())
{
TotalEnergy += energy;
}
}
MoleEnergy is the joules wrapper (new MoleEnergy(double joules)). Adding to an empty atmosphere is a no-op in the second form; the first form has no such guard. Temperature is derived from energy + heat capacity via IdealGas.Energy(HeatCapacity, T), so depositing energy implicitly raises temperature unless there is no gas to hold it.
TransferEnergyTo(ref GasMixture target, MoleEnergy signedEnergyToTransfer) (line 358893) and (HeatSink target, MoleEnergy) (line 421749) are the conduction primitives; they call RemoveEnergy on the source and AddEnergy on the destination, clamping by both sides' available capacity. Heat exchangers (HeatExchangerBase, WallCooler), pipe heaters, and the rocket thermal plant use TransferEnergyTo.
Typical caller form (one of dozens across the assembly):
Global / planetary atmosphere energy¶
For deposits to the unenclosed world atmosphere outside any sealed room, there is a separate static API at PlanetaryAtmosphereSimulation:
public static void AddEnergy(MoleEnergy energy)
{
if (!IsGlobalInteraction || energy <= MoleEnergy.Zero) return;
lock (GlobalInteraction)
{
ExternalInputEnergyOffset += energy;
}
}
(decompile line 200541). This is the path for "the whole planet absorbed N joules" rather than per-cell deposits. Power dissipation should NOT use this path: it is for global thermal accounting (sun, planet-wide events). Use the per-cell GasMixture.AddEnergy instead.
Resolving "which atmosphere am I in"¶
Three resolution paths exist, gated by call context:
-
Per-cell local (server, main / atmospherics thread):
base.AtmosphericsController.SampleGlobalAtmosphere(WorldGrid)(read-only, returns the local atmosphere if one exists, falls back to a read-only global wrapper) orbase.AtmosphericsController.CloneGlobalAtmosphere(WorldGrid, 0L)(writable, server-only: throws if called on a client, see line 184887throw new System.Exception("Clone Global Atmosphere called on Client")). -
Per-cell local (thread-safe form):
AtmosphericsManager.CloneGlobalAtmosphereThreadSafe(WorldGrid)(line 418542) takes theAllWorldAtmospheresLookUplock before delegating toCloneGlobalAtmosphere. Use this from atmospheric worker threads or anywhere the lock invariant matters. -
Through a device: a
DevicehasWorldAtmosphere(cached, refreshed fromCloneGlobalAtmosphere),InternalAtmosphere(its own sealed volume if any, e.g. canister, pipe-mounted internal), andNetworkAtmosphere(the connected pipe network's atmosphere viaDevicePipeMounted->NetworkAtmosphere). A pipe-mounted heater deposits intoNetworkAtmosphere; a device that heats the room around it deposits intoWorldAtmosphere.
The cell map is private static readonly Dictionary<WorldGrid, Atmosphere> AllWorldAtmospheresLookUp inside AtmosphericsManager (line 417826), and Find(WorldGrid) is a locked TryGetValue. See ../GameClasses/Atmosphere.md for the full Room <-> Atmosphere binding.
Atmosphere.IsAboveArmstrong() (the boiling-pressure-of-water threshold) is the conventional gate before depositing heat into the world atmosphere: in near-vacuum there is nothing to hold the energy, so most devices skip the deposit (if (atmosphere.IsAboveArmstrong())).
Device.OnAtmosphericTick: the canonical convection-from-electrical-load pattern¶
The single most-replicated pattern in the codebase: a Device's EnergyToHeatRatio (default 0.2) multiplied by UsedPower is added to the world atmosphere each atmospheric tick when the device is on, powered, and in a non-vacuum cell. This lives on Device itself (line 350985), so every Device subclass inherits it.
Verbatim from Device.cs (Assembly-CSharp.dll decompile line 349717 and 350985-350997):
protected virtual float EnergyToHeatRatio => 0.2f;
...
public override void OnAtmosphericTick()
{
base.OnAtmosphericTick();
if (OnOff && Powered && (object)PowerCable != null && EnergyToHeatRatio > 0f
&& base.AtmosphericsController.SampleGlobalAtmosphere(base.WorldGrid).IsAboveArmstrong())
{
Atmosphere atmosphere = base.AtmosphericsController.CloneGlobalAtmosphere(base.WorldGrid, 0L);
float usedPower = GetUsedPower(PowerCable.CableNetwork);
if (usedPower > 0f)
{
atmosphere.GasMixture.AddEnergy(new MoleEnergy(usedPower * EnergyToHeatRatio));
}
}
}
Notes on the formula:
UsedPoweris the device's per-tick power demand in watts. TheMoleEnergyconstructor takes joules. Since oneAtmosphericTickcorresponds toAtmosphericsManager.Instance.TickSpeedSecondsof game time (typically 0.5 s on default settings, seeThreadedManager.TickSpeedSeconds => (float)TickSpeed / 1000fat line 202278), this is dimensionally watts * unitless ratio passed as joules without a per-tick multiplier. The game treatsUsedPower * EnergyToHeatRatioas a joules-per-tick quantity, not strictly watts; the ratio absorbs the tick-time conversion. A mod adding heat fromCableNetwork.Required(watts) similarly does not need to multiply byTickSpeedSecondsto match vanilla intensity.EnergyToHeatRatio = 0.2fis the base default. Subclass overrides observed:BatteryandAreaPowerControl(ElectricalInputOutput):0f(storage devices do not radiate heat from throughput).Transformer:0.05f(5% of throughput becomes heat).- All other
Devicesubclasses inherit0.2funless explicitly overridden. - The deposit goes into the world atmosphere of the device's own cell (
WorldGrid), not the room atmosphere directly. The room's gas mixture is composed of its grids' atmospheres; depositing into one cell raises that cell's temperature and conduction propagates it. There is no "deposit to the room's GasMixture" API.
Other deposit shapes observed in vanilla¶
Beyond the Device.OnAtmosphericTick pattern, the assembly contains these distinct deposit shapes:
Constant-rate world deposit (NetworkAtmosphere): PipeHeater (line 363815-363842):
public float HeatTransferJoulesPerTick = 1000f;
public override void OnAtmosphericTick()
{
base.OnAtmosphericTick();
if (OnOff && Powered && base.NetworkAtmosphere != null && base.NetworkAtmosphere.IsValid()
&& base.NetworkAtmosphere.IsAboveArmstrong()
&& base.NetworkAtmosphere.Temperature < WallHeater.MAXTemperature)
{
base.NetworkAtmosphere.GasMixture.AddEnergy(new MoleEnergy(HeatTransferJoulesPerTick));
_powerUsedDuringTick = HeatTransferJoulesPerTick;
}
}
Note the temperature ceiling (< WallHeater.MAXTemperature) and the deposit into NetworkAtmosphere (the pipe network's gas), not WorldAtmosphere.
Per-power deposit with PowerEfficiency: BurningFurnace-style at line 279717:
The ratio is a named PowerEfficiency field instead of the inherited EnergyToHeatRatio.
Constant lit/burning marker: Grenade / similar burning items at line 306071:
Combustion OnFireConsume: the universal fire-consumption path (Thing.OnFireConsume, line 302453) locks the atmosphere, removes oxygen, adds CO2, and adds heatEnergyReleased as energy:
lock (atmosphere)
{
if (atmosphere.GasMixture.Oxygen.Quantity > FireConsumeMoleAmount)
{
atmosphere.GasMixture.Oxygen.Remove(FireConsumeMoleAmount);
atmosphere.GasMixture.CarbonDioxide.Add(FireConsumeMoleAmount, MoleEnergy.Zero);
atmosphere.GasMixture.AddEnergy(new MoleEnergy(heatEnergyReleased));
return true;
}
...
}
The lock (atmosphere) is the per-atmosphere lock used for cross-thread mutation; see "Threading" below.
Battery base atmospheric tick (heat-from-power-discharge in cold): Battery.OnAtmosphericTick at line 371140-371160 (despite the EnergyToHeatRatio => 0f override, the base battery does dump heat into its world cell when discharging, but ONLY when above Armstrong AND temperature below freezing point, scaling with one-atmosphere-clamped ratio). The deposit is the cold-loss energy (PowerStored -= num; atmosphere.GasMixture.AddEnergy(new MoleEnergy(num));).
Lungs / Entity body heat: Lungs.TakeBreath (line 284520-284526) and Entity.EnergyReleasedPerTick = 100f (line 338712). Human body heat is EnergyReleasedPerTick added to the lung atmosphere (the entity's internal pocket), not to the world / room. The room only receives this via exhalation conduction back through BreathingAtmosphere. So HumanPlayer's "warms the room" effect is indirect.
Pipe heat conduction along network (the closest analog to "compute across N segments"): AtmosphereHelper.GetConvectionHeat(a, b, area) returns the joules transferable per call; the caller (e.g. HeatExchangerBase line 358781, WallHeatExchanger line 366744) multiplies by AtmosphericsManager.Instance.TickSpeedSeconds and feeds it into TransferEnergyTo. There is no aggregate "loop over all pipe segments, deposit to each" primitive; each device computes its own per-tick exchange against its InputNetwork.Atmosphere / OutputNetwork2.Atmosphere. A "cable network heat dissipation" feature has no direct vanilla precedent: the closest is the pipe-heater-per-network pattern, which is still device-driven, not network-driven.
Device table¶
| Device | Formula (joules per atmospheric tick) | Target atmosphere | Resolution | Override on EnergyToHeatRatio | Notes |
|---|---|---|---|---|---|
Generic Device (default base) |
UsedPower * 0.2f |
WorldAtmosphere of device cell |
CloneGlobalAtmosphere(WorldGrid, 0L) |
inherits 0.2 | Gated on OnOff && Powered && PowerCable != null && IsAboveArmstrong |
Transformer |
UsedPower * 0.05f |
WorldAtmosphere |
same | overrides to 0.05 | Inefficiency-as-heat |
Battery |
0f from base; cold-discharge path adds num joules (50 in cold, 10 normal) on top in its own override |
WorldAtmosphere |
CloneGlobalAtmosphere |
overrides to 0 (skips base path) | Cold-only path lives in Battery.OnAtmosphericTick itself |
AreaPowerControl |
0f |
n/a | n/a | overrides to 0 | Storage / passthrough |
PipeHeater |
1000f constant when on+powered |
NetworkAtmosphere (pipe) |
inherited from DevicePipeMounted.NetworkAtmosphere |
inherits 0.2 (but pipe path uses constant) | Temperature ceiling = WallHeater.MAXTemperature |
Furnace / burning-style |
EnergyReleasedPerTick constant |
WorldAtmosphere |
CloneGlobalAtmosphere |
varies | Sets Sparked = true |
Grenade (when armed) |
100.0 constant |
WorldAtmosphere |
CloneGlobalAtmosphere |
n/a | Sets Sparked = true |
Lungs (Human breath) |
Entity.EnergyReleasedPerTick = 100f |
LungAtmosphere (internal) |
direct ref | n/a | Room sees this only through exhalation conduction |
CombustionDeepMiner |
inherits combustion path (OnFireConsume -> heatEnergyReleased -> world cell) |
WorldAtmosphere |
combustion atmosphere | n/a | See ../GameClasses/CombustionDeepMiner.md |
HeatExchangerBase |
convectionHeat * TickSpeedSeconds between InputNetwork.Atmosphere and InputNetwork2.Atmosphere |
both networks (transfer) | TransferEnergyTo |
n/a | Conduction primitive; not generation |
Tick driver, threading, and multiplayer authority¶
Where OnAtmosphericTick runs. The per-tick dispatcher is AtmosphericsManager.ThingAtmosphereTick() (line 418214):
ThingAtmosphereTickAction (line 417874) invokes each thing's OnAtmosphericTick. The call site is in the central game tick loop at decompile line 189476:
await UniTask.SwitchToThreadPool(); // line 189436
...
if (RunSimulation)
{
AtmosphericsManager.BeforeAtmosphericsTick();
AtmosphericsController.World.DoAtmosphereMixJobs();
AtmosphericsController.World.RunInternalReactionsJobs();
SubmergedHandler.Tick();
AtmosphericsManager.ThingAtmosphereTick(); // <-- THIS CALL
AtmosphericsManager.LifeTicksTick();
AtmosphericsManager.AtmosphericsNetworksTick();
...
}
So:
OnAtmosphericTickruns on a ThreadPool worker, not the Unity main thread. ADevicepatch that touches Unity APIs (transforms, materials, GameObject lifecycle) directly from inside anOnAtmosphericTickoverride will fail or race; marshal viaUnityMainThreadDispatcher.Instance().Enqueue(...)if needed.OnAtmosphericTickruns only whenRunSimulationis true, which means host only in multiplayer (single-player counts as host). Clients do not invokeOnAtmosphericTick. The atmosphere state is then replicated to clients viaAtmosphericsManager.SerialiseDeltaStateAction(line 418011) on the per-tick delta packet.CloneGlobalAtmospherethrows on client:if (Assets.Scripts.Networking.NetworkManager.IsClient) throw new System.Exception("Clone Global Atmosphere called on Client")(line 184887). A heat-emission patch that runs on both sides would crash on the client.
Thread-safety of GasMixture.AddEnergy. The method is not internally locked. Concurrent access is governed by two patterns:
- The atmosphere is per-cell; the worker dispatcher routes a given
Atmosphereto one worker per tick (AtmosphericsWorker.Assign(atmosphere)). Within one tick, only one worker mutates a given atmosphere's GasMixture. - For cross-thread main-thread events (e.g. construction, ignition, mod-driven sparks), the
AtmosphericEventInstance.CreateAddEnergy/CreateRemoveEnergy/CreateAdd/CreateRemovequeue (line 184227-184329) defers the mutation to the main-threadAtmosphericEventInstance.HandleNetworkChangedEvents()pump inAtmosphericsController.HandleMainThreadEvents()(line 184948). This is the "I want to deposit energy but I am on the wrong thread or in the wrong phase" escape hatch:
public static void CreateAddEnergy(Atmosphere atmosphere, MoleEnergy energy, bool spark)
{
if (GameManager.RunSimulation)
{
AddEvent(new AtmosphericEventInstance(AtmosphericEventAction.AddEnergy, atmosphere, energy, spark));
}
}
The event-apply (line 184573) reuses GasMixture.AddEnergy and additionally marks the atmosphere Sparked if requested. Use the event form when:
- You are on the main thread and need to defer to the atmospherics phase.
- You hold an Atmosphere reference but cannot guarantee phase ordering.
- You want sparks (auto-ignition).
Use direct atmosphere.GasMixture.AddEnergy(new MoleEnergy(x)) when:
- You are inside an OnAtmosphericTick override (on the worker thread, in the right phase, your cell's atmosphere is already assigned to your worker).
- You do not need a spark.
Save / load. GasMixture is serialised as part of the per-atmosphere save record (AtmosphericsManager.SerialiseOnJoin at line 417994, Atmosphere.Write / Atmosphere.Read family). The internal energy _energy / TotalEnergy is therefore persisted across save / load and replicated to clients on join. Heat deposited via AddEnergy is durable; it does not need a separate save hook.
Patterns for adding heat from outside an OnAtmosphericTick¶
A "per-CableNetwork dissipation" feature does not have a vanilla per-tick callback on CableNetwork. The available hooks, ranked by fit:
-
Iterate
CableNetworkfrom inside anOnAtmosphericTickpostfix on a sentinel device. Bad: introduces ordering coupling on which device is the sentinel; the device might not be on the network being dissipated. -
Iterate
CableNetwork.AllCableNetworksfrom a periodic main-thread tick. Cable resistance / dissipation is conceptually per-network, not per-device; pull the aggregate fromCableNetwork.Consumed(orRequired) and the cable list, compute per-segment heat in joules, then deposit. Three sub-options for where to deposit:
a. One deposit per cable (most physical): for each Cable c in network.CableList, resolve c.GridController.AtmosphericsController.CloneGlobalAtmosphere(c.WorldGrid, 0L) and AddEnergy(new MoleEnergy(perCableJoules)). Cost: O(N) CloneGlobalAtmosphere calls per network per tick. Each call is a dictionary lookup plus an optional new Atmosphere(...) allocation on first access.
b. Aggregate by atmosphere cell (cheaper): pre-group cables by WorldGrid (or by Atmosphere reference), call AddEnergy once per unique cell with the summed joules.
c. Deposit only to one canonical cell (cheapest, least physical): e.g. the lowest-id cable's cell, or the cell of the highest-load consumer. Easy but breaks "long cable run heats up along its length".
-
Hook into the existing per-cable
OnAtmosphericTick. Cable isSmallGrid, notDevice; it does NOT have anOnAtmosphericTickoverride and is not inAtmosphericThings, so this is not directly available. Adding cables toAtmosphericsManager.AtmosphericThingsis one option but requires the cable to implement / register itself like aDevice. -
Replace / shadow
PowerTick.Re-Voltalready does this (RevoltTick : PowerTick) — see../GameClasses/Cable.md. A heat patch could piggyback on the samePowerTick.Runpostfix and walk each network'sCableListto deposit per-cable. Runs on the power tick thread; same threading caveats asOnAtmosphericTick. See../GameSystems/PowerTickThreading.mdfor the power tick threading contract. -
Use
AtmosphericEventInstance.CreateAddEnergyto defer the deposit to the main-thread atmospheric event pump from anywhere (including a power-tick callback). Trades direct mutation for queue cost; safest cross-phase.
Recommendation for a per-CableNetwork heat dissipation feature: pattern 4 (postfix on PowerTick.Run or whatever consumed-power calculation already exists in this mod's Power Grid Plus) combined with pattern 2b (aggregate-per-cell deposit via AddEnergy on the worker thread, since PowerTick already runs there). Aggregating by cell minimizes CloneGlobalAtmosphere calls. If the dispatch is from outside an atmospheric / power tick phase, use AtmosphericEventInstance.CreateAddEnergy instead.
Multiplayer: gate every deposit on RunSimulation (GameManager.RunSimulation) so it runs only on the host. Client atmospheres receive the energy via the standard delta-state replication. Never call CloneGlobalAtmosphere from a client; use SampleGlobalAtmosphere (read-only) or skip the path entirely on NetworkManager.IsClient.
Performance shape¶
Device.OnAtmosphericTick runs per registered atmospheric thing per atmospheric tick, on the ThreadPool. With N devices and one atmospheric tick per 0.5 s (default), the per-device cost is one SampleGlobalAtmosphere (dictionary lookup) + one CloneGlobalAtmosphere (dictionary lookup + occasional allocation) + one AddEnergy (a denormal / NaN check + a field add). A single network with K cables, billed per-cable in a heat-dissipation feature, multiplies that K-fold.
AllAtmospheres is a ConcurrentDensePool (line 417824) sized for 65535. AtmosphericThings is a DensePool sized for 32768. Iteration is a straight foreach over a contiguous backing buffer; the per-iteration cost is small but multiplied by tick rate.
A cable count in the thousands per network is realistic in late-game. Aggregating by WorldGrid (pattern 2b above) collapses K cable-deposits into M cell-deposits where M is bounded by the network's cell footprint. For a one-dimensional long cable run this still O(K), but cables sharing a cell (cable junctions, dense panels) collapse meaningfully.
AtmosphericsManager.TickSpeed is configurable; do not assume 500 ms. Use AtmosphericsManager.Instance.TickSpeedSeconds when a feature's heat rate is expressed in watts. Vanilla EnergyToHeatRatio does not multiply by TickSpeedSeconds; if it did, a higher tick rate would also raise the per-tick deposit. The implicit contract is "the ratio is calibrated for the default tick rate".
Verification history¶
- 2026-05-22: page created from a heat-emission API survey for a planned per-
CableNetworkcable-dissipation feature inMods/PowerGridPlus. Sourced verbatim from.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.cslines listed insources:. New page; no conflicts with existing central content (cross-checked againstResearch/GameClasses/Atmosphere.md,Research/GameSystems/DevicePowerDraw.md,Research/GameSystems/PowerTickThreading.md,Research/GameClasses/CombustionDeepMiner.md,Research/GameClasses/PipeIgniter.md).
Open questions¶
- Confirm exact dimensionality of
EnergyToHeatRatio * UsedPower. The argument tonew MoleEnergy(...)is joules per docs, but the deposit happens once per atmospheric tick (~0.5 s default). The decompiled formula does not multiply byAtmosphericsManager.Instance.TickSpeedSeconds, so the "ratio" effectively encodes a per-tick fraction rather than a thermodynamic efficiency. A future verification pass could measure observed temperature rise on a known load and confirm the implicit tick-scale. - Whether
Cableinstances can be added toAtmosphericThingsto gain a per-cableOnAtmosphericTickwithout subclassingDevice. TheAtmosphericsManager.Registerpath (called fromDevice.OnRegisteredline 351004) is the entry point; whether it tolerates a non-DeviceThingregistration safely is not yet verified.