Device¶
Vanilla powered-structure base class. Assets.Scripts.Objects.Pipes.Device : SmallGrid, ILogicable, IReferencable, IEvaluable, IConnected, ISlotWriteable, IWreckage, IPowered, IDensePoolable (line 349588). The base type of every grid-mounted structure that has at least one Connection and that participates in a CableNetwork / PipeNetwork / ChuteNetwork. Vanilla electrical machines (lights, fabricators, generators, transformers, batteries, APCs, etc.) all derive from Device (often through ElectricalInputOutput for two-port devices).
Class header, key fields, public surface¶
public class Device : SmallGrid, ILogicable, IReferencable, IEvaluable, IConnected,
ISlotWriteable, IWreckage, IPowered, IDensePoolable // line 370335
{
[Header("Device")]
[Tooltip("How much power (in Watts) does the device used while turned on.")]
public float UsedPower = 10f;
[ReadOnly] public List<ChuteNetwork> ConnectedChuteNetworks = new List<ChuteNetwork>();
[ReadOnly] public List<PipeNetwork> ConnectedPipeNetworks = new List<PipeNetwork>();
[ReadOnly] public List<CableNetwork> ConnectedCableNetworks = new List<CableNetwork>();
[ReadOnly] public List<Cable> AttachedCables = new List<Cable>();
public Cable DataCable { get; set; }
public Cable PowerCable { get; private set; }
public Cable[] DataCables { get; private set; } = Array.Empty<Cable>(); // line 370460
public Cable[] PowerCables { get; private set; } = Array.Empty<Cable>(); // line 370462
public CableNetwork DataCableNetwork => DataCable == null ? null : DataCable.CableNetwork;
public CableNetwork PowerCableNetwork => PowerCable == null ? null : PowerCable.CableNetwork;
public Connection DataConnection => OpenEnds.Find(c =>
c.ConnectionType == NetworkType.Data || c.ConnectionType == NetworkType.PowerAndData);
public virtual bool IsPowerProvider => false;
public virtual bool IsPowerInputOutput => false;
}
Version change at 0.2.6403.27689: DataCables / PowerCables are now Cable[] auto-properties defaulting to Array.Empty<Cable>() (previously List<Cable>), rebuilt wholesale by FindDataCable / FindPowerCable; the singular DataCable / PowerCable are set to the first array element.
Subclasses of note: Transformer : ElectricalInputOutput : Device (two ports, Input / Output ConnectionRole), PowerTransmitter : Device, Battery : Device, SolarPanel : Device : IPowerGenerator, every furnace / fabricator / pipe valve / chute conveyor. DeviceCableMounted : Device is the in-cable family (CableFuse, CableAnalyser) -- it sits inside a cable's grid cell rather than adjacent to one, but otherwise behaves like a Device.
PowerCable resolution: every assignment goes through FindPowerCable¶
Device.PowerCable is a property with a public getter and a private setter, so it can only be assigned from inside Device itself. There is exactly one place that assigns it: Device.FindPowerCable() (line 371588). At 0.2.6403.27689 the body is Span-based (the old ConnectedCables(NetworkType) API is REMOVED from the game; see CursorAdjacencyLookup):
private void FindPowerCable()
{
Span<SmallCellRef> span = stackalloc SmallCellRef[32];
int count = 0;
FillConnected<Cable>(NetworkType.Power, span, ref count);
PowerCables = new Cable[count];
if (count == 0)
{
PowerCable = null;
AssessPower(null, OnOff);
return;
}
Span<SmallCellRef> span2 = span;
Span<SmallCellRef> span3 = span2.Slice(0, count);
for (int i = 0; i < span3.Length; i++)
{
PowerCables[i] = span3[i].Get<Cable>();
}
PowerCable = PowerCables[0];
}
So PowerCable is always the first hit of FillConnected<Cable>(NetworkType.Power, ...) (SmallGrid.FillConnected<T>(NetworkType, Span<SmallCellRef>, ref int), line 312896), which walks the device's OpenEnds in declaration order, projects each end's transform position through GridController.WorldToLocalGrid, fetches the SmallCell at that grid coordinate, and records any adjacent occupant whose own IsConnected(openEnd) agrees, as a lazily-resolved SmallCellRef. Same pipeline as before, allocation-free. See CursorAdjacencyLookup for the migrated API surface and the cursor-time story.
There is no direct PowerCable = ... write from a cable-side trigger, a CableNetwork.Merge hook, or anywhere else in Assembly-CSharp. Cable.OnRegistered(Cell) (line 392523) only runs CableNetwork.Merge(CableNetwork.ConnectedNetworks(this)).Add(this); it does not push itself into adjacent devices. The wiring is always pulled from the device side.
The decomp grep confirms it at 0.2.6403.27689: every PowerCable = assignment resolves to lines 371596 (PowerCable = null;) and 371606 (PowerCable = PowerCables[0];), both inside FindPowerCable. No other write site exists.
InitializeDataConnection: the wrapper that finds both cables and refreshes network membership¶
public void InitializeDataConnection() // line 371609
{
FindDataCable();
FindPowerCable();
DataCableNetwork ?.DirtyPowerAndDataDeviceLists();
PowerCableNetwork?.DirtyPowerAndDataDeviceLists();
}
FindDataCable (line 371568; note it is PUBLIC while FindPowerCable is private) is the data-cable twin of FindPowerCable, again filling from FillConnected<Cable>(NetworkType.Data, ...) into the DataCables array and taking the first result as DataCable (null when the array is empty).
InitializeDataConnection is the single entry point that refreshes (Power|Data)Cable after the world changes shape around the device.
When InitializeDataConnection runs (i.e. when PowerCable becomes valid)¶
Three call sites in vanilla, all on the live world (not on cursor ghosts):
public override void OnRegistered(Cell cell) // line 350999
{
_centerPosition = base.ThingTransformPosition + ThingTransform.rotation * Bounds.center;
base.OnRegistered(cell);
LocalGrid = base.GridController.WorldToLocalGrid(_centerPosition);
AtmosphericsManager.Instance.Register(this);
AllDevices.Add(this);
if (GameManager.GameState != GameState.Loading)
{
InitializeDataConnection();
InitializeDevice();
}
}
public override void OnNeighborPlaced(SmallGrid neighbor) // line 351038
{
base.OnNeighborPlaced(neighbor);
if (GameManager.GameState != GameState.Loading)
InitializeDataConnection();
}
public override void OnNeighborRemoved(SmallGrid neighbor) // line 351047
{
base.OnNeighborRemoved(neighbor);
InitializeDataConnection();
}
public static readonly Action<Device> InitializeDeviceAction = delegate(Device device) // line 349636
{
if (!(device == null) && !device.IsBeingDestroyed)
{
device.InitializeDevice();
device.InitializeDataConnection();
}
};
public static void InitAllDevices() // line 351053
{
AllDevices.ForEach(InitializeDeviceAction);
}
So PowerCable is set / refreshed on:
- device registration (
OnRegistered, when a placed device first lands in a non-loading world) - neighbour placement / removal (
OnNeighborPlaced/OnNeighborRemoved, when a cable is built next to the device or removed) - bulk init (
InitAllDevices, called after save load and after multiplayer client join, seeOnFinishedLoadplumbing inGameManager)
InitializeDevice (line 350958) additionally registers the device into every adjacent cable's CableNetwork (item.CableNetwork.AddDevice(item, this), line 350962), which in turn appends the network to device.ConnectedCableNetworks (line 253836 inside CableNetwork.AddDevice).
OnRegistered (live world) vs CanConstruct (cursor preview)¶
Device.CanConstruct() (line 371617) runs on the cursor ghost every frame. At 0.2.6403.27689 it is FillConnected-based:
public override CanConstructInfo CanConstruct()
{
Span<SmallCellRef> buf = stackalloc SmallCellRef[32];
int count = 0;
FillConnected<Device>(buf, ref count);
for (int num = count - 1; num >= 0; num--)
{
if (buf[num].TryGet<INetworkedPipe>(out var found) && !found.ProhibitConnection(this))
{
count--;
}
}
if (count > 0 && buf[0].TryGet<Device>(out var found2))
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementBlockedByAdjacentDevice.AsString(found2.DisplayName));
}
if (count > 0)
{
return CanConstructInfo.InvalidPlacement(GameStrings.PlacementBlockedByUnknownDevice.DisplayString);
}
return base.CanConstruct();
}
This is verbatim evidence that the FillConnected adjacency pipeline (openEnd.Transform.position -> WorldToLocalGrid -> GetSmallCell -> occupant IsConnected(openEnd)) already works on the cursor ghost at preview time; it reads the live transform, not the cached Connection.LocalGrid. A mod postfix on Device.CanConstruct that needs the would-attach cable cannot call the removed ConnectedCables(NetworkType.Power) and (on net472) cannot bind the Span-typed FillConnected either; use the Connection.GetLocalGrid() + SmallCell.Get<Cable>(grid, openEnd) pattern documented on CursorAdjacencyLookup.
Caveats:
PowerCableitself is not set on the cursor ghost (noFindPowerCableruns during preview).ConnectedCableNetworksis not populated on the cursor ghost either; that list is only appended byCableNetwork.AddDevice, which only runs on the registered device.Connection.GetCable()/GetDevice()and the other occupant getters return null on a cursor ghost's own OpenEnds (GetSmallGridOccupantbails on!_isInitialized, andSetGridsnever initializes cursor-parented connections); see CursorAdjacencyLookup.- The cursor ghost has
IsCursor == true,tag == "Cursor",IgnoreSave == true, no colliders, no children Renderers swapped for ghost materials, and is reused across frames (one per prefab in_constructionCursors). It is not a fresh instance per placement.
Power virtuals: where PowerCable gates a device¶
The default power virtuals only deliver power when the calling network matches the device's own PowerCable.CableNetwork:
public virtual float GetGeneratedPower(CableNetwork cableNetwork) // line 371501
{
if (PowerCable == null || PowerCable.CableNetwork != cableNetwork)
return -1f;
return 0f;
}
public virtual float GetUsedPower(CableNetwork cableNetwork) // line 371510
{
if (PowerCable == null || PowerCable.CableNetwork != cableNetwork)
return -1f;
if (!OnOff || !base.IsStructureCompleted)
return 0f;
return UsedPower;
}
public virtual bool AllowSetPower(CableNetwork cableNetwork) // line 371531
{
return PowerCableNetwork == cableNetwork;
}
A device whose PowerCable is null (no adjacent power cable) silently returns -1f from GetUsedPower / GetGeneratedPower, which PowerTick reads as "not on this network". Many subclasses override these three methods; the PowerCable == null || PowerCable.CableNetwork != cableNetwork guard is replicated almost verbatim across vanilla power devices (0.2.6228.27061 line census: 165086, 169955, 344689, 345179, 350698, 350707, 359403, 371227, 374356, 375228, 387488, 397078, 398880, 401392; e.g. PowerTransmitterOmni.GetUsedPower at 0.2.6403 line 408692).
Mid-tick admission: AssessPower books DuringTickLoad between power ticks¶
Device.AssessPower(CableNetwork, bool) (protected virtual, lines 371654-371685) is the between-tick admission check that decides whether a device switched on mid-tick lights up immediately or waits for the next power tick:
- It books the device's
GetUsedPower(cableNetwork)intoCableNetwork.DuringTickLoadagainst the network'sEstimatedRemainingLoad => PotentialLoad - CurrentLoad - DuringTickLoad(CableNetwork line 270676). - Within budget (
usedPower <= EstimatedRemainingLoad): adds the full draw toDuringTickLoadand flipsPoweredon immediately viaSetPower(371640-371646,OnServer.Interact(InteractPowered, ...)gated onGameManager.RunSimulation). - Over budget: books
Min(usedPower, EstimatedRemainingLoad)intoDuringTickLoad(saturating the remaining headroom) and powers the device off. cableNetwork == null || !isOnpowers off and books nothing.
DuringTickLoad is zeroed at the top of each CableNetwork.OnPowerTick (line 270834), so the bookings only bridge the gap until the next real tick recomputes everything. The vanilla trigger is Device.OnInteractableUpdated (371692-371695): an OnOff interactable change while GameState.Running && RunSimulation && HasPowerState calls AssessPower(PowerCable?.CableNetwork, state == 1). FindPowerCable also calls AssessPower(null, OnOff) when the device loses its last power cable (371597), powering it off immediately.
Two per-device draw-state fields make GetUsedPower reflect prior-tick state¶
Two private float fields cause a device's GetUsedPower to encode work or throughput from the PREVIOUS tick rather than the live tick. Both matter to any mod that ticks a network more than once per game tick (it will read the same field in every pass, see "single-pass vs multi-pass" below).
_powerProvided (one-tick lag). Exactly FOUR classes carry a private float _powerProvided (0.2.6403.27689 whole-decompile census): AreaPowerControl (line 390592), PowerReceiver (408071), PowerTransmitter (408287), and Transformer (424621). It is the accumulator of the power drawn THROUGH the device by its downstream consumers: added to in the device's UsePower and decremented in ReceivePower as input power flows in (see PowerTick: ApplyState -> ConsumePower -> Device.ReceivePower). The device's input-side draw is reported FROM this accumulator:
- Transformer.GetUsedPower(InputNetwork) = min(Setting + UsedPower, _powerProvided) (line 424791).
- AreaPowerControl.GetUsedPower(InputNetwork) = Max(_powerProvided, UsedPower) (+ a cell-charge term) (391028-391044).
- PowerTransmitter.GetUsedPower(InputNetwork) = min(MaxPowerTransmission, _powerProvided) (408469); its _powerProvided accrues from the WirelessNetwork output (UsePower gated on cableNetwork == WirelessOutputNetwork, 408426) and decrements on input-cable ReceivePower (408442).
- PowerReceiver.GetUsedPower(InputNetwork) = min(PowerTransmitter.MaxPowerTransmission + UsedPower, _powerProvided) (408229); the receiver's InputNetwork is the WIRELESS network (assigned from LinkedPowerTransmitter.OutputNetwork), so this bills the wireless side, not a cable. PowerReceiver.GetGeneratedPower(OutputNetwork) = WirelessInputNetwork.PotentialLoad (408242), and its _powerProvided accrues from its OutputNetwork draws (UsePower, 408188).
_powerProvided is NEVER zeroed anywhere in the game: the whole-decompile census finds exactly the four declarations above and only += / -= / read sites (APC 391004-391037, RX 408188 / 408202 / 408229, TX 408428 / 408442 / 408469, Transformer 424761 / 424769 / 424791); no plain _powerProvided = ... assignment exists. Residual or negative debt therefore persists across OnOff toggles, error states, and link changes for the life of the object; nothing but the object's destruction resets the ledger.
_powerProvided is also NOT serialized (0.2.6403.27689 census), so "the life of the object" is bounded by the session. No save record carries it: TransformerSaveData (424593-424597) holds only OutputSetting; WirelessPowerSaveData (426765-426778) holds only the four dish-rotation doubles (Horizontal / Vertical / TargetHorizontal / TargetVertical); PowerTransmitterSaveData (408264-408268) adds only OutputNetworkReferenceId; PowerReceiverSaveData (408062-408064) adds nothing; AreaPowerControl declares no save-data class and no serialization override anywhere in its class body (390555-391146). The same whole-decompile reference census doubles as the wire proof: no SerializeOnJoin / DeserializeOnJoin / BuildUpdate / ProcessUpdate member reads or writes the field. The only runtime write dispatch is inside PowerTick.ApplyState on the power worker: PowerProvider.ApplyPower (271690-271696) -> UsePower, and PowerTick.ConsumePower (271820-271840) -> ReceivePower (see PowerTick); the rocket umbilical's direct PartnerUmbilical.ReceivePower(null, ...) crossing (158139 / 158624) dispatches only onto RocketPowerUmbilical halves, never onto the four ledger classes. Consequence: the ledger is per-session runtime accumulation. It restarts at the C# default 0 whenever the object is recreated, which includes save load and client join; a pre-save debt or credit does not survive into the loaded world, and a late-joining client starts every ledger at 0 regardless of the host's value. Scope: verified at 0.2.6403.27689; older game versions unverified.
Because _powerProvided is filled during ApplyState (which runs AFTER CalculateState has already summed GetUsedPower), the value a CalculateState reads is last tick's downstream consumption: the input-side draw a pass-through device bills lags its output-side delivery by exactly one tick. This is the mechanism behind the "transformer free-power / one-tick-lag" exploit family that Power Grid Plus replaces with a fresh allocator-computed throughput. Battery and RocketPowerUmbilical do NOT carry _powerProvided -- they are terminal storage (a PowerStored cell), so their charge/discharge draw is a function of live cell state, not a lagged pass-through accumulator.
_powerUsedDuringTick (per-tick impulse, on processing machines: ArcFurnace, Centrifuge, Recycler, Fabricators, filtration machines, robotics chargers, etc.). This is the IMPULSE energy a machine consumes doing work in a tick (a recipe's Energy, EnergyPerSmelt, atmosphere-proportional energy, etc.). The machine's GetUsedPower adds it on top of the base UsedPower: e.g. ArcFurnace.GetUsedPower returns UsedPower + _powerUsedDuringTick (0.2.6228 line 345177; the same UsedPower + _powerUsedDuringTick return / = 0f-in-ReceivePower reset pattern is confirmed still present across the 0.2.6403.27689 decompile, e.g. lines 365558 / 365564 with += _currentRecipe.Energy at 365606). It is set during the machine's processing (e.g. _powerUsedDuringTick += _currentRecipe.Energy, += EnergyPerSmelt) and RESET to 0 in ReceivePower. Because the reset happens once, inside ApplyState, the field holds the SAME value across every CalculateState read within a single game tick. So GetUsedPower is idempotent within a tick (an observe pass and an enforce pass in the same tick read the same impulse), even though the value changes tick-to-tick as the machine starts and finishes work.
Single-pass vs multi-pass implication: vanilla runs Initialise -> CalculateState -> ApplyState once, so it reads each field once and resets it once. A mod whose tick reads GetUsedPower in more than one pass (e.g. an OBSERVE pass and an ENFORCE pass) gets the SAME _powerUsedDuringTick in both (reset only happens in the single ApplyState), but if it tries to compute supply from one device's draw and feed it to another device, the _powerProvided lag means the pass-through input draw it observes is one tick stale relative to the live downstream demand. Matching freshly-computed supply against _powerProvided-reported demand mixes a live figure with a one-tick-old figure.
Operational-state surface: OnOff, Powered, PoweredValue, Error, IsOperable¶
The "is this device switched on, and does it actually have power right now" question is answered by a set of animator-state-backed properties declared on Thing (the universal base), refined by IsOperable overrides further down the hierarchy. None of these live on Device itself; they are inherited.
Error is animator display state; writers are event-driven (0.2.6403 write-path census)¶
Thing.Error at 0.2.6403.27689 (property 317927-317957, verbatim):
public virtual int Error
{
get
{
if (HasErrorState && !HasBaseAnimator)
{
return InteractError.State;
}
if (ThreadedManager.IsThread || _frameErrorUpdated == Time.frameCount)
{
return _error;
}
_frameErrorUpdated = Time.frameCount;
_error = (((bool)BaseAnimator && HasErrorState) ? BaseAnimator.GetInteger(Interactable.ErrorState) : 0);
return _error;
}
set
{
if (HasErrorState)
{
if ((bool)BaseAnimator)
{
SetIntegerSafe(Interactable.ErrorState, value);
}
else
{
InteractError.State = value;
}
_error = value;
}
}
}
So Error is DISPLAY state: it is stored in the animator integer Interactable.ErrorState (or the InteractError.State slot when the prefab has no base animator), read through a per-frame cache (_frameErrorUpdated / _error; worker threads always get the cached value), and the setter is local-only (it touches the animator and the cache, sets no network flag, and sends nothing). The animator write guard every Thing state setter funnels through is SetIntegerSafe (319497-319503, verbatim):
private void SetIntegerSafe(int stateId, int value)
{
if ((object)BaseAnimator != null && BaseAnimator.HasParameter(stateId))
{
BaseAnimator.SetInteger(stateId, value);
}
}
An animator without the named integer parameter silently swallows the write (no exception, no visible state). All the Thing state-property setters (Button/Error/Lock/Mode/Activate/Import/Export/OnOff/Powered/Open/Color/Access, call sites 317850-318339), the bulk animator-state refresh (319343-319399), and the generic interactable path (319513) go through it.
Write-path census over the whole 0.2.6403.27689 decompile (a text census of the two mechanisms that can change Error: OnServer.Interact(*.InteractError, ...) calls, which route through the Interactable.State funnel and replicate, and direct Error = <expr> property writes, which are local display only):
- 257
OnServer.Interact(*.InteractError, ...)call sites. The dominant shape is a per-class privateCheckError()that comparesIsOperableagainst the currentErrorand flips it 0/1 underGameManager.RunSimulation;Transformer(424944-424957) andBattery(391986-391999) carry byte-identical bodies of this shape, andWirelessPowerhas a protected variant (427072) that the dish pair invokes on link changes (408096, 408320). - A handful of direct
Error = <expr>property writes:RocketMinermining-head presence checks (389414 / 389424),DeviceInputOutputreset to 0 (374288),Appliancereset to 0 (432580), and a scanning-head device's checks (42534 / 42544; declaring class not resolved this pass). - Every writer found is event-driven: cable-network attach/detach, interactable updates, link changes, head-presence checks, state-machine transitions. For the power-bridge classes specifically, the only writer is their
CheckError, and its callers are topology or toggle events (Transformer:OnAddCableNetwork/OnRemoveCableNetwork/ next-frame recheck fromOnInteractableUpdated, 424959-424984; Battery:OnAddCableNetwork/OnRemoveCableNetwork, 392013-392023, plus the next-frame-deferredWaitCheckStaterecheck, 392038-392050). Nothing callsCheckErrorfrom the power tick, so overload or shortfall during steady-state operation never raisesError = 1on these classes. A partial-power forensics pass (the PowerGridPlus occasion for this census) must not expect vanillaErrorto flag brownouts;Error == 1on a bridge means a topology/operability fault was detected at an event boundary, nothing else.
Thing-level state properties (declared on Thing, line 297636)¶
All four are public virtual, backed by a Unity Animator integer parameter, and cached per-frame. Source Assets/Scripts/Objects/Thing.cs:
public virtual bool OnOff // line 299160
{
get
{
if (HasOnOffState && !HasBaseAnimator)
return InteractOnOff.State == 1;
if (ThreadedManager.IsThread || _frameOnOffUpdated == Time.frameCount)
return _onOff;
_onOff = (bool)BaseAnimator && HasOnOffState && BaseAnimator.GetInteger(Interactable.OnOffState) == 1;
_frameOnOffUpdated = Time.frameCount;
return _onOff;
}
set { /* writes Interactable.OnOffState on the animator + _onOff */ }
}
public virtual bool Powered => PoweredValue >= 1; // line 299193
public virtual int PoweredValue // line 299195
{
get
{
if (HasPowerState && !HasBaseAnimator)
return InteractPowered.State;
if (ThreadedManager.IsThread || _framePoweredUpdated == Time.frameCount)
return _powered;
_powered = ((bool)BaseAnimator && HasPowerState) ? BaseAnimator.GetInteger(Interactable.PoweredState) : 0;
_framePoweredUpdated = Time.frameCount;
return _powered;
}
set { /* writes Interactable.PoweredState on the animator + _powered */ }
}
public virtual int Error // line 298838
{
get
{
if (HasErrorState && !HasBaseAnimator)
return InteractError.State;
if (ThreadedManager.IsThread || _frameErrorUpdated == Time.frameCount)
return _error;
_frameErrorUpdated = Time.frameCount;
_error = (((bool)BaseAnimator && HasErrorState) ? BaseAnimator.GetInteger(Interactable.ErrorState) : 0);
return _error;
}
set { /* writes Interactable.ErrorState on the animator + _error */ }
}
Re-read verbatim at 0.2.6403.27689 (2026-07-13): the excerpt above is byte-identical apart from line numbers. New refs: Thing class 316720, OnOff 318249-318280, Powered => PoweredValue >= 1 318282, PoweredValue 318284-318315 (Error at 317927-317957 is quoted in the Error subsection above). The same getter shape (HasXxxState && !HasBaseAnimator -> InteractXxx.State; ThreadedManager.IsThread || _frameXxxUpdated == Time.frameCount -> cached field; else read the animator and refresh the cache) also covers Exporting2 (318223-318247) and IsOpen (318317-318348). ThreadedManager.IsThread is Thread.CurrentThread.ManagedThreadId != GameManager.MainThreadId (217769; GameManager.IsThread at 203949 is the same predicate). The CacheStates excerpt below keeps its 0.2.6228 line ref (not re-read this pass).
Backing fields (also on Thing): private bool _onOff; (line 298075), protected int _powered; (line 298079), private int _error;, plus the per-frame guards _frameOnOffUpdated / _framePoweredUpdated / _frameErrorUpdated. The Has*State flags (HasOnOffState, HasPowerState, HasErrorState, declared line 298143-298154) are [ReadOnly] bools, but the [ReadOnly] attribute is only a Unity Inspector decorator and says nothing about the data source. They are assigned by Thing.CacheStates() (line 302678) from the Interactables list, one Exists test per flag against the matching InteractableType, NOT from the animator's parameter list:
public void CacheStates(bool cacheInteractables = false) // line 302678
{
HasLockState = Interactables.Exists(i => i.Action == InteractableType.Lock);
HasErrorState = Interactables.Exists(i => i.Action == InteractableType.Error);
HasPowerState = Interactables.Exists(i => i.Action == InteractableType.Powered);
HasOnOffState = Interactables.Exists(i => i.Action == InteractableType.OnOff);
// ... HasModeState / HasOpenState / HasActivateState / HasExport(2)State /
// HasImport(2)State / HasButton1-3State / HasColorState / HasAccessState, all same shape ...
}
This is the sole write site for each flag (exhaustive grep: one assignment each, all inside CacheStates; no animator-sourced setter exists anywhere in Assembly-CSharp). The animator is the DOWNSTREAM consumer, not the source: once a flag is set, the getters above read the animator only while the flag is true (e.g. OnOff evaluates BaseAnimator.GetInteger(Interactable.OnOffState) == 1 gated on HasOnOffState, and falls through to false when the flag is false). Practical consequence: a device whose prefab carries no Interactable with Action == InteractableType.OnOff (no on/off toggle, e.g. solar panels, both wind turbines, the RTG, the bare power-connector dock) reports OnOff == false permanently; likewise a device with no Powered interactable reports Powered == false always. This matters for any logic that treats OnOff == false as "the player switched it off": for a buttonless device that is not an OFF gesture, it is the absence of an OFF concept, so such logic must additionally gate on HasOnOffState to tell the two apart.
Caching / threading semantics that matter for a mod reader:
- On the main thread, the getter reads the live
Animator.GetInteger(...)once per frame and caches into the_xxxfield (keyed byTime.frameCount). Subsequent same-frame reads return the cache. - On a background thread (
ThreadedManager.IsThread, e.g. insidePowerTick.ApplyStateon the UniTask ThreadPool worker), the getter short-circuits to the cached_onOff/_powered/_errorfield WITHOUT touching theAnimator(Unity API calls from a worker thread crash the player). So from a power-tick-adjacent patch these properties are safe to read and return last-frame's value. This is exactly why PowerTransmitterPlus readst.OnOff/t.Errorfrom its power-tick postfixes without aMainThreadDispatcherround-trip (Mods/PowerTransmitterPlus/PowerTransmitterPlus/LogicReadoutPatches.cs:44:if (t == null || !t.OnOff || t.Error == 1) return 0f;).
Visual-state plumbing: the animator integer IS the storage, and each visual surface keys off one property¶
Three consequences of the getter shape above, verified end to end at 0.2.6403.27689.
1. There is no separate render flag. For a Thing WITH a BaseAnimator, the Unity animator integer parameters (Interactable.PoweredState / Interactable.OnOffState) are the storage for PoweredValue / OnOff: the getter reads BaseAnimator.GetInteger(Interactable.PoweredState) (318296) and the setter writes back through SetIntegerSafe (318306). For an animator-less Thing the Interactable slot is the storage (return InteractPowered.State;, 318290). Visual state, logical flag, and the IC10 values share that one storage: Device.GetLogicValue returns LogicType.On => OnOff ? 1 : 0 (371178-371179) and LogicType.Power => Powered ? 1 : 0 (371180-371181). Two gates sit in front of those arms: GetLogicValue returns 0.0 for ANY logic type while !IsStructureCompleted (the pre-switch early-out at the top of the method body, 371164 region), and CanLogicRead(LogicType.Power) returns HasPowerState (371028/371041), so a prefab without a Powered interactable never exposes the Power slot at all. The Thing.OnOff getter has the matching guard: it returns false when !HasOnOffState before consulting the animator or interactable storage (318249 region).
2. Every interactable write, local or replicated, lands in the animator and refreshes the visuals. The funnel is the Interactable.State setter (304441-304460): it assigns _state (304449), notifies the parent Thing (304450), and then triggers the visual refresh (304458):
public virtual void OnInteractableStateChanged(Interactable interactable, int newState, int oldState) // Thing, 319509-319515
{
if ((bool)BaseAnimator)
{
SetIntegerSafe(interactable.PropertyId, newState);
}
}
public virtual void OnInteractableUpdated(Interactable interactable) // Thing, 319517-319522
{
CacheAnimatorInteractableVariable(interactable.Action);
RefreshAnimState(GameManager.GameState != GameState.Running);
this.OnInteractable?.Invoke();
}
3. Which property each visual surface reads. Info screens are Powered-ONLY. Device.RefreshAnimState (371700-371707) forwards to the optional info-screen component (protected InfoScreenComponent _infoScreen;, 370366):
protected override void RefreshAnimState(bool skipAnimation = false) // Device, 371700-371707
{
base.RefreshAnimState(skipAnimation);
if (_infoScreen != null)
{
_infoScreen.RefreshState(this);
}
}
public class InfoScreenComponent : GameBase // 33633-33647
{
[SerializeField]
private MaterialChanger _materialChanger;
[SerializeField]
private Collider infoTrigger;
public Collider InfoTrigger => infoTrigger;
public void RefreshState(Device parent)
{
_materialChanger.ChangeState(parent.Powered ? Defines.Animator.OnPowered : Defines.Animator.NotPowered);
}
}
No OnOff term: an info screen lights whenever Powered == 1, whatever the switch says. The same Powered-only keying gates the IC-housing info-panel tooltip: DeviceInputOutputImportExportCircuit.GetPassiveTooltip shows the operation text only when hitCollider == _infoScreen.InfoTrigger && Powered (375983).
Switch levers are OnOff-first. SwitchOnOff.RefreshColorState (146597-146629, on the DevicePart visual-component family, driven from RefreshState at 146575-146582):
protected virtual void RefreshColorState(bool skipAnim) // SwitchOnOff, 146597-146629
{
SwitchColorState switchColorState = SwitchColorState.Off;
if (parentThing.OnOff)
{
switchColorState = ((parentThing.Powered || !parentThing.HasPowerState) ? SwitchColorState.OnPowered : SwitchColorState.On);
}
if ((parentThing.Error != 0 && parentThing.Powered) || (parentThing.Error != 0 && !parentThing.HasPowerState))
{
switchColorState = SwitchColorState.Error;
}
if (switchColorState != _currentColorState)
{
_currentColorState = switchColorState;
CancelErrorAnimation();
switch (_currentColorState)
{
case SwitchColorState.Off:
switchRenderer.material = off;
break;
case SwitchColorState.On:
switchRenderer.material = on;
break;
case SwitchColorState.OnPowered:
switchRenderer.material = onPowered;
break;
case SwitchColorState.Error:
_errorStateCancellationTokenSource = new CancellationTokenSource();
ErrorAnimation(_errorStateCancellationTokenSource.Token).Forget();
break;
}
}
}
Off wins unconditionally: OnOff == false selects the off material regardless of Powered. On and Powered selects onPowered; on and unpowered selects on (the !parentThing.HasPowerState term promotes things with no Powered interactable straight to onPowered, since their Powered reads false permanently per the CacheStates subsection above). The Error state blinks error[0] / error[1] every 250 ms (ErrorAnimation, 146641-146661). The base class has no OffPowered case; SwitchMode (146456-146502) adds the fourth material offPowered (field 146459) with this decision ladder (146480):
SwitchColorState switchColorState = ((parentThing.GetInteractable(State).State != 1) ? ((!parentThing.Powered) ? SwitchColorState.Off : SwitchColorState.OffPowered) : (parentThing.Powered ? SwitchColorState.OnPowered : SwitchColorState.On));
The enum is the game's designed four-plus-state switch visual model:
Why the two keyings never visibly disagree in vanilla. Toggling OnOff off fires Device.OnInteractableUpdated -> AssessPower(net, isOn) (371687-371698), and AssessPower un-powers immediately (the same body the "Mid-tick admission" subsection above describes; verbatim, 371654-371685):
protected virtual void AssessPower(CableNetwork cableNetwork, bool isOn) // Device, 371654-371685
{
if (cableNetwork == null || !isOn)
{
if (Powered)
{
SetPower(cableNetwork, hasPower: false);
}
return;
}
float usedPower = GetUsedPower(cableNetwork);
if (usedPower <= 0f)
{
return;
}
if (usedPower > cableNetwork.EstimatedRemainingLoad)
{
cableNetwork.DuringTickLoad += Mathf.Min(usedPower, cableNetwork.EstimatedRemainingLoad);
if (Powered)
{
SetPower(cableNetwork, hasPower: false);
}
}
else
{
cableNetwork.DuringTickLoad += usedPower;
if (!Powered)
{
SetPower(cableNetwork, hasPower: true);
}
}
}
Independently, PowerTick.ApplyState un-powers zero-demand and unfed devices every tick (OFF edge 271936-271938 inside the device loop 271916-271940; see PowerTick). So in vanilla OnOff = 0 forces Powered = 0 within the frame, and the Powered-only surfaces (info screens, the 375983 tooltip gate) never light on a switched-off device.
Mod context. A mod that holds Powered = 1 while OnOff = 0 exposes every Powered-only-keyed surface: info screens stay lit, offPowered-material switches glow, and 375983-style Powered gates stay open. And because the animator integer IS the storage (point 1), there is no hidden render flag to patch: re-keying a surface means patching the visual component itself (InfoScreenComponent.RefreshState, SwitchOnOff.RefreshColorState) or changing the stored value, nothing in between.
Network sync: animator state, server-driven¶
OnOff, PoweredValue, and Error are server-authoritative and synced to clients as part of the Thing animator-state replication, not as bespoke NetworkUpdateFlags bits. The write path is always through OnServer.Interact(Interactable, int) on the host, which sets the Interactable.State and drives the corresponding animator integer; the animator-state delta then ships to clients. The power state specifically is set every power tick from IPowered.OnPowerTick() implementations via OnServer.Interact(base.InteractPowered, 0|1) (dozens of call sites; e.g. lines 277774/277781, 279291/279306, 308728/308732, 326535/326540). OnOff is toggled by player interaction or logic-write, again funnelled through OnServer.Interact(base.InteractOnOff, ...).
Implication: a client reading device.Powered / device.OnOff / device.Error sees the host's value (synced), so these are valid to read on either side. A mod must NOT write them on a client; do gameplay writes on the server (NetworkManager.IsServer) and let the existing animator sync propagate.
IsOperable: the closest thing to a single "on and powered" predicate (but it is NOT that)¶
IsOperable is a protected property (not the public combined predicate one might hope for). Two declarations matter on the dish hierarchy:
// Device, line 349675
protected virtual bool IsOperable
{
get
{
if (base.IsStructureCompleted) return !IsBroken;
return false;
}
}
// ElectricalInputOutput : Device, line 373803
protected override bool IsOperable
{
get
{
if (OutputNetwork != null && InputNetwork == OutputNetwork)
return false; // input and output shorted to the same network
return base.IsOperable; // -> IsStructureCompleted && !IsBroken
}
}
IsOperable does NOT consult OnOff or Powered. It means "structurally complete, not broken, and (for two-port devices) not self-shorted." protected visibility means a mod cannot read it directly without reflection. It feeds Error, not the other way around (see WirelessPower.CheckError below).
Beware a naming collision: a parallel DraggableThing-family hierarchy (PortableAtmospherics, DynamicGenerator, etc.) declares IsOperable as a method public virtual bool IsOperable() (lines 277715, 279310, 289260) returning true by default. That method is unrelated to the Device property and is not on the dish path.
How to read "on and powered, right now" for a Device subclass¶
There is no single vanilla property meaning "switched on AND receiving power." The canonical runtime check is the explicit conjunction the game itself uses across power code:
OnOff(public bool): the switch state. Player/logic controlled, server-set, synced.Powered(public bool, ==PoweredValue >= 1): set by the power tick when the device's network actually delivered its demand this tick. Server-set, synced. This is the "not browned out" signal.Error == 0(public int, 0 = ok / 1 = error): excludes the misconfigured / self-shorted / broken case. Server-set fromIsOperable, synced.
All three are public on Thing, so a mod reads them directly off any Device instance (including PowerTransmitter / PowerReceiver) on either client or server, including from a background power-tick thread (cached value). For the dish pair specifically, PowerReceiver.LinkedPowerTransmitter's setter also drives Mode (1 = linked, 0 = unlinked) via OnServer.Interact(base.InteractMode, ...) (line 386885), so receiver.Mode == 1 is the synced "is linked" signal distinct from "is powered."
Vanilla Powered writer census (every write path, 0.2.6403.27689)¶
Whole-decompile census of everything that writes a Device's Powered flag (produced for the PowerGridPlus Powered-ownership redesign; three independent agents concur, key sites re-read directly). The write funnel: Device.SetPowerFromThread (NON-virtual, public async UniTaskVoid, 371648-371652, marshals to the main thread) -> virtual Device.SetPower (371640-371646: if (Powered != hasPower && GameManager.RunSimulation) OnServer.Interact(InteractPowered, hasPower ? 1 : 0)) -> the replicated interactable. OnServer.Interact itself (39690-39703) returns unless GameManager.RunSimulation and queues to the main thread when called from a worker, so every write below is host-only and replicates to clients as normal interactable state. SetPower overrides: Bench (325448-325463, propagates OnOff && Powered into every docked Appliance) and ElevatorLevel (395278-395285, ORs ShaftNetwork.IsAnyOtherPowered into the value).
- The tick writer.
PowerTick.ApplyState: ON edge 271929-271933 (consumed with power met, or a provider with ratio above 0; NOT AllowSetPower-gated), OFF edge 271936-271938 (the game's ONLY AllowSetPower-gated write). Both callSetPowerFromThread, its only vanilla callers. - Mid-tick admission.
Device.AssessPower(protected virtual, 371654-371685): both edges from theEstimatedRemainingLoadheuristic, on the MAIN thread. Triggers: every OnOff interactable change (OnInteractableUpdated371692-371694),FindPowerCablefinding zero power cables (AssessPower(null, OnOff), 371597),OnRemoveCableNetwork(371738-371745). Overrides that REPLACE the admission:LandingPadTankConnector176535,LandingPadPump185583,LandingPadTaxiThreshold185861 (allSetPower(net, LandingPadCenter.OnOff), re-fired from their ownOnPowerTick),Bench325465,UnPoweredDoor327581 (SetPower(net, true)unconditionally),WallLightBattery327950 (delegates toCheckPowerState). - Event-driven un-power families.
ElectricalInputOutput.CheckPower(394945-394951: OFF whenInputNetwork == null && Powered; fired from network add/remove 394986-395006);AreaPowerControl.CheckPower(390983-390989: OFF onNoPower, fired from OnOff interaction and battery-slot events);Battery.CheckPower(391963-391969: BOTH edges every host device tick, syncingInteractPoweredto the computed charge-state getter, which is why writing a Battery's interactable never sticks). - Per-device-tick self-asserters.
WallLightBattery.CheckPowerState(328028-328038:Powered = OnOff && HasPower(cell), re-fired from AssessPower / ReceivePower / OnPowerTick / OnInteractableUpdated / slot events 327950-328056);PowerGeneratorPipe.OnAtmosphericTick(396677-396714: both edges from combustion state) andStirlingEngine.OnAtmosphericTick(424014-424079), the two classes whoseAllowSetPowerreturns false (see PowerTick); the elevator family (ElevatorShaftNetwork.ShaftPowerUpdated395914-395927 copies the aggregate onto cable-less shafts;ElevatorCarrage.PhysicsUpdate203364-203373 re-syncs the carriage every physics frame);SpawnPointAtmosphericsadditionally self-toggles OnOff fromPowerTick.Potential(422696-422702). - Out-of-scope self-owners (never on a cable network's PowerDeviceList): the Bench appliance cascade (Appliance writes at 300-306, 432574-432694, plus the
BenchPowerStateChangedoverrides), self-gauging vehicles (Rover226302-226331,RobotMining308792-308817:PoweredValueis a 0-5 battery GAUGE, not a boolean),Shuttle(310264-310270, directInteractPowered.State =on both peers), and the portable / wearableCheckPowerStatefamily (Suit, Helmet, PowerTool, SensorLenses, theDynamic*portables, Defibrillator, GasMask, LanderCapsule). - The forwarding producer.
PowerConnector.ConnectedDynamicGenerator(public field, 408007) is set/cleared on dock/undock (408023-408048);PowerConnector.GetGeneratedPower(408014) forwards the dockedDynamicGenerator.PowerGenerated(gated on the generator's OnOff/Powered, 297398-297408) to ANY asking network.DynamicComposter(296224) is aDraggableThingsibling ofDynamicGenerator(297342), so it can never dock as a generator: its threaded accumulator is unreachable by the power solve.
Consequence for a mod taking ownership of Powered: blocking the ApplyState OFF edge at the SetPowerFromThread funnel silences the only tick-driven un-power for every class at once (including third-party AllowSetPower overrides), but the event-driven writers above survive, and the per-device-tick self-asserters get the last word each tick (the device tick runs after any ENFORCE-tail write), so those classes must be exempted rather than driven.
Verification history¶
- 2026-07-13 (third pass): extended the visual-state plumbing subsection's IC10 paragraph with the two front gates, re-read from the 0.2.6403.27689 decompile during the PowerGridPlus Power-read-override build:
GetLogicValuereturns0.0for any logic type while!IsStructureCompleted(pre-switch early-out, 371164 region),CanLogicRead(LogicType.Power)returnsHasPowerState(371028/371041), andThing.OnOffreturnsfalsewhen!HasOnOffStatebefore consulting storage (318249 region). Additive; no prior content contradicted. - 2026-07-13 (later): added the "Visual-state plumbing" subsection to the operational-state section (game version 0.2.6403.27689), all bodies re-read from the decompile this pass. The chain: animator integers are the storage for
PoweredValue/OnOff(getter animator read 318296, animator-lessInteractPowered.State318290, setter 318306; the IC10LogicType.On/LogicType.Powerarms read the same properties, 371178-371181); theInteractable.Statesetter funnel (304441-304460) drivesThing.OnInteractableStateChanged(319509-319515, stamps the animator viaSetIntegerSafe) andThing.OnInteractableUpdated->RefreshAnimState(319517-319522), both quoted verbatim;Device.RefreshAnimState(371700-371707) forwards toInfoScreenComponent.RefreshState(33633-33647, keyed onparent.PoweredONLY, no OnOff term; same keying as theDeviceInputOutputImportExportCircuit.GetPassiveTooltipgate at 375983);SwitchOnOff.RefreshColorStatequoted verbatim in full (146597-146629, OnOff-first with the!HasPowerStatepromotion, Error blink 146641-146661, no OffPowered case in the base);SwitchModeaddsoffPowered(146456-146502, ladder 146480);SwitchColorStateenum verbatim (146504-146512);AssessPowerquoted verbatim (371654-371685, immediateSetPower(false)when!isOn), triggered fromDevice.OnInteractableUpdated(371687-371698), plus the per-tickPowerTick.ApplyStateOFF edge (271936-271938), which together forcePowered = 0within the frame of an OnOff-off in vanilla. Occasion: a mod holdingPowered = 1whileOnOff = 0lights every Powered-only surface; recorded the re-keying constraint (animator-is-storage means no separate render flag exists). Additive; no prior content contradicted. - 2026-07-13: fresh-validation pass at 0.2.6403.27689 (decompile-claim audit). (a) Restamped "Thing-level state properties":
OnOff/Powered/PoweredValuere-read verbatim at 318249-318315 (byte-identical to the 0.2.6228 excerpt; a worker thread returns the cached_onOff/_poweredwithout touching the animator),Exporting2(318223-318247) andIsOpen(318317-318348) confirmed to share the shape,ThreadedManager.IsThreadlocated at 217769; theCacheStatesexcerpt keeps its 0.2.6228 ref, marked inline. (b) Re-confirmed the Powered-writer census's tick-writer claim by whole-decompile grep:SetPowerFromThreadis declared once (Device, 371648,await UniTask.SwitchToMainThread(); SetPower(cableNetwork, hasPower);) and called exactly twice, both inPowerTick.ApplyState(271933 ON edge, 271938 OFF edge); no other vanilla system marshalsPoweredwrites through it. No content contradicted. The destroyed-parent drop semantics of theOnServer.Interactfunnel these writes land in are now on OnServer. - 2026-07-09 (later): added the "Vanilla Powered writer census" section from the PowerGridPlus Powered-ownership redesign investigation (game version 0.2.6403.27689): the SetPowerFromThread -> SetPower -> OnServer.Interact funnel with its RunSimulation gate, the ApplyState ON/OFF edges as SetPowerFromThread's only vanilla callers, the AssessPower admission (triggers + the six overrides), the CheckPower / CheckPowerState event families, the per-device-tick self-asserters, the out-of-scope self-owner list, and the PowerConnector -> DynamicGenerator forwarding chain with the DynamicComposter sibling dead-end. Additive; the existing operational-state sections stand.
- 2026-07-09: re-confirmed the operational-state surface (
OnOff/Powered/PoweredValue/InteractOnOff/InteractPowered) and the baseDevice.GetUsedPowerOnOff gate verbatim against the 0.2.6403.27689 decompile; no content change (the section already carriesGetUsedPowerreturning 0 when!OnOff || !base.IsStructureCompletedat 371510-371521,Powered => PoweredValue >= 1read offInteractPowered.Stateat 318282, and "Powered set by the power tick"). Incremental fact recorded this pass: the IC10 logic-type mapping.LogicType.Onis the writableOnOffswitch (SetLogicValue(LogicType.On, ...)gates onOnOff, 153191-153205);LogicType.Poweris the read-only energized flag;PowerTick.ApplyState -> SetPowerFromThread -> InteractPoweredis the sole writer ofPowered. SoOnandPowerare two distinct networked states (two interactables:Thing.HasStatemapsOnOffState -> InteractOnOffandPoweredState -> InteractPowered, 320106-320134), not one, and a switched-off device readsPower == 0only indirectly (OnOff false makesGetUsedPowerreturn 0, soApplyStateun-powers it the next tick). Occasion: explaining the OnOff-vs-Powered distinction behind a PowerGridPlus fabricator reboot (aPoweredtransition; the switch never moved). - 2026-07-07: added "Error is animator display state; writers are event-driven" subsection to the operational-state section (game version 0.2.6403.27689). Verbatim
Thing.Errorproperty (317927-317957: animator-integer display state with per-frame cache, worker threads read the cache, setter local-only viaSetIntegerSafe/InteractError.State) andThing.SetIntegerSafe(319497-319503: writes only when the animator has the named parameter). Whole-decompile write-path census: 257OnServer.Interact(*.InteractError, ...)sites (dominant shape: per-classCheckError()flipping 0/1 onIsOperableunderRunSimulation; Transformer 424944-424957 and Battery 391986-391999 byte-identical, WirelessPower protected variant 427072 called at 408096 / 408320) plus the directError =writes (RocketMiner 389414/389424, DeviceInputOutput 374288, Appliance 432580, unresolved scanner 42534/42544). All writers event-driven; no power-tick caller for the bridge classes. Occasion: PowerGridPlus partial-power forensics floated the claim "no vanilla Error writer for Transformer"; the census rejects the literal claim (CheckError exists and writes) while confirming the operative half (no steady-state/per-tick writer, so brownouts never raise Error). Additive here; the corresponding Transformer.md section was re-verified the same pass. - 2026-07-06: added the non-serialization census to the
_powerProvidedhalf of "Two per-device draw-state fields" (game version 0.2.6403.27689). Before writing, checked every central page mentioning_powerProvidedfor a claim that the ledger persists into saves: none exists (this page's "for the life of the object" wording was the closest and already implies reset on recreation), so the addition is additive scoping and no fresh validator was required. Evidence, all re-read from the 0.2.6403.27689 decompile this pass: whole-decompile_powerProvidedcensus re-run (four declarations, only the known+=/-=/ read sites);TransformerSaveData424593-424597 (onlyOutputSetting);WirelessPowerSaveData426765-426778 (only H/V + slew targets);PowerTransmitterSaveData408264-408268 (adds onlyOutputNetworkReferenceId);PowerReceiverSaveData408062-408064 (empty);AreaPowerControlclass body 390555-391146 contains noSerializeSave/InitialiseSaveData/DeserializeSave/SerializeOnJoin/DeserializeOnJoin/BuildUpdate/ProcessUpdatemember (next typeAtmosphericSeatat 391147); caller census for.UsePower(/.ReceivePower(confirmsPowerProvider.ApplyPower(271690-271696) andPowerTick.ConsumePower(271820-271840) are the only dispatch sites that can reach the four ledger classes (the umbilical crossing at 158139 / 158624 targetsRocketPowerUmbilicalpartners; the single-argumentappliance.ReceivePower(float)at 287 / 325489 is a different overload on the appliance hierarchy). Restamped the subsection. - 2026-07-02: re-verification and update pass against the 0.2.6403.27689 decompile after the game update from 0.2.6228.27061. (a) NEW fact in the draw-state subsection:
_powerProvidedis never zeroed anywhere in the game; whole-decompile census finds exactly four declarations (AreaPowerControl 390592, PowerReceiver 408071, PowerTransmitter 408287, Transformer 424621) and only+=/-=/ read sites, no plain assignment, so residual or negative debt persists for the life of the object. Also corrected the APC input-draw formula in that subsection fromUsedPower + _powerProvidedto the actualMax(_powerProvided, UsedPower)+ charge term (verified at 391028-391044; the old wording was imprecise even at 0.2.6228, see AreaPowerControl). (b) NEW "Mid-tick admission: AssessPower" subsection (AssessPower371654-371685 books intoCableNetwork.DuringTickLoadagainstEstimatedRemainingLoad => PotentialLoad - CurrentLoad - DuringTickLoadat 270676, flipsPoweredviaSetPower371640-371646, over-budget booksMin(usedPower, EstimatedRemainingLoad)and powers off;DuringTickLoadzeroed eachOnPowerTickat 270834; triggered fromOnInteractableUpdated371692-371695 andFindPowerCable371597). © API-migration supersessions:SmallGrid.ConnectedCables/ConnectedDeviceswere removed from the game;FindPowerCable(371588) /FindDataCable(371568, now public) /CanConstruct(371617) areFillConnected-based andDataCables/PowerCablesareCable[]auto-properties (370460 / 370462, previouslyList<Cable>); replaced the three superseded verbatim excerpts with the 0.2.6403 bodies and re-ran the single-write-site census forPowerCable(only 371596 / 371606, both insideFindPowerCable). Updated class decl (370335), power virtuals (371501-371534), and per-class draw refs to the new decompile. Sections "When InitializeDataConnection runs" and the Thing-level operational-state sections were NOT re-read this pass and keep their 0.2.6228.27061 stamps. - 2026-06-18: added "Two per-device draw-state fields make GetUsedPower reflect prior-tick state" subsection. Additive; no existing content changed. Driving question: a Power Grid Plus rewrite that reports fresh allocator-computed transformer supply matched against pass-through input draws produced a one-tick mismatch (a battery main feeding two Area Power Controllers flickered:
ENFORCE Required(t) == allocator-demand(t-1)). Root-caused to_powerProvided(the downstream-consumption accumulator carried by exactly four classes -- Transformer, AreaPowerControl, PowerTransmitter, PowerReceiver -- filled inApplyStateafterCalculateStatealready summedGetUsedPower, so the input-side draw lags the output-side delivery by one tick; Battery and RocketPowerUmbilical are terminal storage and do NOT carry it) vs_powerUsedDuringTick(the processing-machine impulse, reset once inReceivePower, hence idempotent across multipleCalculateStatereads within a tick). Decompile evidence (0.2.6228.27061):ArcFurnace.GetUsedPower=UsedPower + _powerUsedDuringTick(line 345177), reset at 345193;_powerUsedDuringTick +=setters at 164536/168673/169933/278095/278103/345226/359308/359382/361442/363841 etc. (recipe / smelt / atmosphere / charge energies);_powerProvidedmutation viaApplyState->ConsumePower->Device.ReceivePowerdocumented on PowerTick. Live confirmation via a ScenarioRunner consumer dump on the dedicated server: each Area Power Controller'sGetUsedPowerequalledUsedPower + _powerProvided(the stale accumulator) while transformers on the same net reported a fresh figure. - 2026-06-14: conflict on "what populates the
Has*Stateflags (HasOnOffState/HasPowerState/HasErrorState)". Previous claim (added 2026-05-22): set at prefab-init "from the animator's parameter list". New finding: assigned byThing.CacheStates()(line 302678) from theInteractableslist, oneInteractables.Exists(i => i.Action == InteractableType.X)test per flag. Fresh validator verdict: new finding correct; the[ReadOnly]field attribute had been misread as evidence of an animator source, but it is only a Unity Inspector decorator, andCacheStatesis the sole write site (no animator-sourced setter exists). The animator is the downstream consumer (the getters read it gated on the flag), never the source. Result: corrected the "Thing-level state properties" subsection with the verbatimCacheStatesexcerpt and the buttonless-device consequence (OnOff == falsepermanently when noAction == OnOffinteractable exists); restamped that subsection to 2026-06-14 and bumped top-levelverified_at. Driving question: whether PowerGridPlus's OFF-as-reset sweep (which clears fault lockouts on any device reportingOnOff == false) misfires on buttonless producers. - 2026-05-22: added "Operational-state surface: OnOff, Powered, PoweredValue, Error, IsOperable" section. Additive; no existing content changed. Driving question: how does a mod (PowerTransmitterPlus beam fix) know a dish device is switched on and actually powered right now. Findings sourced from
.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.cs:Thing.OnOff(line 299160),Thing.Powered/PoweredValue(lines 299193/299195),Thing.Error(line 298838), backing fields andHas*Stateflags (lines 298075-298154),Device.IsOperableproperty (line 349675),ElectricalInputOutput.IsOperableoverride (line 373803), theOnServer.Interact(base.InteractPowered, ...)power-tick write pattern (lines 277774-334419 passim),IPoweredinterface (line 327392, declares onlyvoid OnPowerTick()), andPowerReceiver.LinkedPowerTransmittersetter drivingMode(line 386885). TheIsOperable()-method-vs-IsOperable-property naming collision (DraggableThing family at lines 277715/279310/289260 vs Device property) noted to prevent a future mis-patch. Cross-checked againstMods/PowerTransmitterPlus/PowerTransmitterPlus/LogicReadoutPatches.cs:44which already uses!t.OnOff || t.Error == 1. - 2026-05-13: page created. Sourced from
.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.cslines 349588-351055 (Device class header, fields, properties, FindPowerCable, InitializeDataConnection, OnRegistered, OnNeighborPlaced, OnNeighborRemoved, CanConstruct, GetGeneratedPower/GetUsedPower/AllowSetPower) and 253820-253850 (CableNetwork.AddDevice). Cross-checked against the Power Grid Plus phase 3 research dive (Mods/PowerGridPlus/RESEARCH.md). Single-write-site finding forPowerCableconfirmed by exhaustive grep againstset_PowerCableandPowerCable =in the decomp (only matches: lines 350786, 350790 inFindPowerCable).
Open questions¶
- Whether
InitializeDataConnectionruns duringConstructor.SpawnConstructon a server build vs only later via theOnRegisteredchain. The decomp suggestsThing.Create->OnRegistered(cell)chain is the only path, but the exactThing.Createflow is not fully traced here.