CableNetwork¶
Vanilla cable network. Each connected island of power cables is one CableNetwork instance, holding the list of Devices attached to it, the Providers array of devices that generate power into this network this tick, the Potential (total generation), Required (total consumption), and the per-tick book-keeping that drives ConsumePower.
CableNetwork lives in the Assets.Scripts.Networks namespace. At 0.2.6403.27689 the declaration is public class CableNetwork : IReferencable, IEvaluable, ILogicable, ISyncListable, IDensePoolable, IMemoryReadable, IMemory (decompile line 270571): the class is now ILogicable / IMemoryReadable / IMemory-bearing (it carries an 8-slot _channels double array, line 270652, and a DisplayName), so a network itself is a logic-readable object. The sibling base StructureNetwork lives in a different namespace called just Networks -- those two namespaces share a folder convention but are distinct in the assembly. The decompile excerpts below are verbatim (0.2.6228 excerpts are marked where not yet re-read).
Provider iteration is single-supplier-first, not load-balanced¶
ConsumePower(Device, CableNetwork, float) walks the Providers array from the END toward index 0 and pulls from each provider until the requested power demand reaches zero. As soon as one provider can satisfy the remaining demand, the walk returns and the rest are untouched.
private bool ConsumePower(Device device, CableNetwork cableNetwork, float powerRequired)
{
int num = Providers.Length;
while (num-- > 0)
{
PowerProvider powerProvider = Providers[num];
if (powerProvider != null && !(powerProvider.Energy <= 0f))
{
float num2 = Mathf.Min(powerRequired, powerProvider.Energy);
powerProvider.Energy -= num2;
powerRequired -= num2;
Consumed += num2;
device.ReceivePower(cableNetwork, num2);
if (powerRequired <= 0f)
{
return true;
}
}
}
return powerRequired <= 0f;
}
Two consequences fall out of this:
- Under-loaded networks: when the network's total
Requiredis less than any one provider'sEnergy, only ONE provider per consumer call provides power. Every other provider seesPowerProvided == 0for that tick, even though their internal generators are running and capable. The "wasted" generation is observable as low_powerProvidedToCableNetworkon the dormant providers and is not a bug. - Overloaded networks: when the demand exceeds one provider's
Energy, the walk continues to the next provider and pulls the residual. Multiple providers contribute proportionally when at least one has been drained to zero, but always in the order specified by the array.
Provider array order¶
Providers is built by CalculateState() from the network's Devices list. The list is walked backwards while collecting providers:
public void CalculateState()
{
_providers.Clear();
_inputOutputDevices.Clear();
int count = Devices.Count;
while (count-- > 0)
{
_currentDevice = Devices[count];
if (_currentDevice == null) continue;
float usedPower = _currentDevice.GetUsedPower(CableNetwork);
if (usedPower > 0f) Required += usedPower;
float generatedPower = _currentDevice.GetGeneratedPower(CableNetwork);
if (generatedPower > 0f)
{
Potential += generatedPower;
PowerProvider item = new PowerProvider(_currentDevice, CableNetwork);
_providers.Add(item);
if (_currentDevice.IsPowerInputOutput) _inputOutputDevices.Add(item);
}
}
Providers = _providers.ToArray();
InputOutputDevices = _inputOutputDevices.ToArray();
CheckForRecursiveProviders();
}
The reverse walk over Devices followed by _providers.Add(item) produces a Providers array whose order is the REVERSE of the Devices list. ConsumePower then walks Providers backwards (num--), which double-reverses to give iteration order = original Devices order.
Net effect: the device that appears earliest in Devices (first added to the network at construction time, typically the lowest ReferenceId of the providers on a given network) is the FIRST one asked to supply each ConsumePower call. Tie-breaking among providers is therefore device-insertion-order, not load-balancing, distance, or heuristic.
Implication for wireless power: only one PowerReceiver supplies a shared network¶
A PowerReceiver is an IsPowerInputOutput device: it appears in both the source-side Devices list (as a consumer) and the destination-side Devices list (as a provider via the wireless link). When multiple PowerReceiver dishes are wired into the SAME destination CableNetwork (for example, seven receiver dishes mounted in a row and all connected to one battery bank), the wireless link from each transmitter delivers power into a shared Providers array on that one network.
Because ConsumePower is single-supplier-first, the network's Required is satisfied by the first provider in iteration order whose Energy is non-zero. The other receivers stay at PowerProvided == 0 for the tick. The matching transmitters (which only pull source power when their receiver is providing into its destination network) likewise stay at PowerProvided == 0 and Powered == false.
Under low or zero destination demand, only one of N parallel TX+RX pairs appears active. This looks like a bug ("six of seven links broken") but is the documented vanilla behaviour: the transmitters and the wireless link are fine; the destination network simply has no work for the other six providers to do.
To distribute load across several wireless links a player must split the destination side into separate cable networks (one per link) or push the consumption past one receiver's Energy capacity so the iteration falls through to the next provider.
Data device list: separate from power device list, walked from the same Cable graph¶
CableNetwork holds two device lists, not one:
_powerDeviceList(exposed asPowerDeviceList): devices that have at least one entry inDevice.PowerCableswhoseCableNetwork == this._dataDeviceList(exposed asDataDeviceList): devices that have at least one entry inDevice.DataCableswhoseCableNetwork == this, plus any devices pulled in viaHandleDataNetTransmissionDevice.
The base DeviceList is the superset; the two typed lists are filtered views rebuilt by RefreshPowerAndDataDeviceLists() (0.2.6403.27689 decompile lines 270746-270787; device.DataCables / device.PowerCables are now Cable[] arrays, see Device):
protected virtual void RefreshPowerAndDataDeviceLists()
{
if (DataDeviceListDirty)
{
_dataDeviceList.Clear();
}
if (PowerDeviceListDirty)
{
_powerDeviceList.Clear();
}
for (int num = DeviceList.Count - 1; num >= 0; num--)
{
Device device = DeviceList[num];
if (DataDeviceListDirty)
{
HandleDataNetTransmissionDevice(device);
Cable[] dataCables = device.DataCables;
for (int i = 0; i < dataCables.Length; i++)
{
if (dataCables[i].CableNetwork == this)
{
_dataDeviceList.Add(device);
break;
}
}
}
if (PowerDeviceListDirty)
{
Cable[] dataCables = device.PowerCables; // decompiler-reused variable name; this is the POWER array
for (int i = 0; i < dataCables.Length; i++)
{
if (dataCables[i].CableNetwork == this)
{
_powerDeviceList.Add(device);
break;
}
}
}
}
PowerDeviceListDirty = false;
DataDeviceListDirty = false;
}
Consequences:
- A device on the network can be in
_powerDeviceListbut not_dataDeviceList(its cables carry power only into this network) or vice versa (a device pulled in byHandleDataNetTransmissionDevicefrom a different network, or one wired with data-only cabling). The two lists are independent filters overDeviceList. - The two lists are walked independently. The power tick uses
_powerDeviceList(PowerTick.Initialise fills itsDevicesfromPowerDeviceList); IC10 /Logicabletraversals use_dataDeviceList. - Power-tick membership requires a power cable on THIS network (the 270772-270783 filter): a device in
DeviceListwith noPowerCablesentry pointing into this network is invisible to this network'sPowerTick(it is neither summed inCalculateStatenor visited byApplyState, so this network's tick can neither power nor un-power it). - The one override:
WirelessNetwork.RefreshPowerAndDataDeviceLists(272486-272492) replaces the filter entirely with_powerDeviceList.AddRange(DeviceList)and leaves_dataDeviceListpermanently empty. A wireless network has no cables, so on it EVERY member ofDeviceList(the linked TX/RX dishes) is a power device and none is a data device. See PowerTransmitter, "WirelessNetwork internals". - Dirtying is split.
DirtyPowerAndDataDeviceLists()dirties both (270816-270820);DirtyDataDeviceList()dirties only the data side (270822-270825).
This is the architectural hook that lets a single physical cable carry both signals while letting a device participate in only one.
Consumer: the Network Analyser reads the raw DeviceList, not DataDeviceList¶
The NetworkAnalyser tablet cartridge (decompile line 331171, public class NetworkAnalyser : Cartridge) populates its screen from the scanned network's base DeviceList field, not from DataDeviceList. GetScannedNetwork() (line 331205) returns the CableNetwork; OnPreScreenUpdate() (line 331273) reads the device count and iterates the device list directly (lines 331287 and 331295):
_devicesValueText = _scannedNetwork.DeviceList.Count.ToString();
...
foreach (Device device in _scannedNetwork.DeviceList)
{
num++;
_tempDeviceOutput += $"\n{StringManager.Get(num)}.{device.DisplayName}";
// ...per-device OnOff / Powered / Open / Error state appended...
}
Consequences:
- The on-screen device list and "Total Devices" count reflect physical cable membership only.
DeviceListis the superset rebuilt from each device'sPowerCables/DataCablespointing into this network (see "Data device list" above); it is never augmented by the data-relay path. Devices merged into_dataDeviceListare NOT shown on the analyser:HandleDataNetTransmissionDevice(the rocket data-link relay) and any mod that augments the data list target_dataDeviceList, never the baseDeviceList. - A mod that makes remote devices logic-visible by augmenting the data list (a postfix on the
DataDeviceListgetter, or onRefreshPowerAndDataDeviceListsthat appends to_dataDeviceList) changes what IC10 batch ops (lb/sb) and logic readers traverse, but does NOT change what the Network Analyser screen displays. Reflecting such a merge on the analyser requires separately patchingNetworkAnalyser.OnPreScreenUpdate, for example a transpiler that redirects the twoDeviceListfield loads to a merged list. Observed in the wild: the third-party OmniLink mod pairs aDataDeviceList-getter postfix with exactly such aNetworkAnalyser.OnPreScreenUpdatetranspiler for this reason.
Refresh cadence: device lists dirty only on structural change, never per tick¶
RefreshPowerAndDataDeviceLists() is invoked lazily from the two property getters (see "Field shape and accessor quirk" below), so it runs only when a dirty flag is set and the list is then read. Every call site that sets a dirty flag is a structural (membership / connectivity) event; none sits on a per-tick or per-frame path. Call sites in Assembly-CSharp.dll (decompile, version 0.2.6228.27061):
DirtyPowerAndDataDeviceLists():
CableNetwork.AddDevice(Cable, Device)line 253849 -- a device gains a cable into this network.CableNetwork.AddDevice(Device)line 253858 -- the virtual overload.CableNetwork.RemoveDevice(Device)line 253892 -- a device leaves the network.CableNetwork.Remove(Cable)line 253966 -- a cable is pulled from the network (afterRefreshNetwork()).CableNetwork.Merge(CableNetwork oldNetwork)line 253995 --oldNetwork.DirtyPowerAndDataDeviceLists()as two networks fuse.Device.InitializeDataConnection()lines 350798-350799 -- dirties bothDataCableNetworkandPowerCableNetworkwhen a device (re)derives its cable connections.
DirtyDataDeviceList() (data side only):
HandleDataNetTransmissionDeviceline 253652 -- the transmitter-push half of the rocket data-link relay dirties each receiver network during a refresh (see the next section).- The
ITransmit/IReceiveDataNetworkDevicesimplementers (RocketDataDownLink/RocketDataUpLink) lines 364937, 365077, 365094, 365182, 365333 -- on data-connection add / remove / config change.
Consequences:
- The lists rebuild on construction, destruction, cable add / remove, network merge / split, device connect, and data-link configuration: all structural events. A device's
Poweredflip (set every tick by the power tick) does NOT dirty either list; membership is independent of power state. So in steady state the cached_dataDeviceListis reused and IC10 batch reads (lb/sb, which walkDataDeviceList) incur no rebuild. - A Harmony postfix on
RefreshPowerAndDataDeviceLists(for example a logic-passthrough merge) therefore fires at structural-change cadence, not per tick. Its cost is amortized over topology edits, not paid on the frame path. - The vanilla call sites dirty only the network that directly changed (plus the explicit
HandleDataNetTransmissionDevicereceiver-push). A mod that makes one network's data list depend on a DIFFERENT network's membership (transitive logic passthrough across a chain of bridge devices) gets no automatic invalidation when the far network changes; it must propagate the dirty itself. Per the accessor quirk below,DirtyDataDeviceList()alone will not force a rebuild on the nextDataDeviceListread (the getter checksPowerDeviceListDirty), so a reliable cross-network invalidation must callDirtyPowerAndDataDeviceLists().
HandleDataNetTransmissionDevice: data devices reach across cable networks¶
Logic devices can talk to other devices on a different CableNetwork without a shared cable run between them. The mechanism is a paired transmitter / receiver, bound by player configuration in the device UI (canonical example: station-side rocket data link configured to talk to rocket-side data link). Each side sits on its own CableNetwork. The link is bridged during the data-list refresh (0.2.6403.27689 decompile lines 270789-270814, shape-identical to the 0.2.6228 body; local variable names normalized in this excerpt):
private void HandleDataNetTransmissionDevice(Device device)
{
if (device is IReceiveDataNetworkDevices receiver
&& receiver.DataConnectionActive()
&& receiver.ConnectedDataNetTransmitter?.DataCableNetwork != null
&& receiver.ConnectedDataNetTransmitter.DataCableNetwork != this)
{
List<Device> remote = receiver.ConnectedDataNetTransmitter.DataCableNetwork.DeviceList;
for (int i = remote.Count - 1; i >= 0; i--)
{
if (!_dataDeviceList.Contains(remote[i]))
_dataDeviceList.Add(remote[i]);
}
}
if (!(device is ITransmitDataNetworkDevices transmitter) || !transmitter.DataConnectionActive())
return;
for (int i = transmitter.ConnectedDataNetReceivers.Count - 1; i >= 0; i--)
{
IReceiveDataNetworkDevices r = transmitter.ConnectedDataNetReceivers[i];
if (r?.DataCableNetwork != null && r.DataCableNetwork != this)
r.DataCableNetwork.DirtyDataDeviceList();
}
}
Two paths:
- Receiver pull. When the local network refreshes its data list and one of the local devices is an
IReceiveDataNetworkDeviceswith an active connection to a transmitter on a differentCableNetwork, the local_dataDeviceListis augmented with every entry from the transmitter'sDeviceList. Logic on this side can then read those remote devices. - Transmitter push request. When the local device is an
ITransmitDataNetworkDeviceswith active receivers on other networks, each remote network's data list is marked dirty so it will pull on its next refresh. The actual copy still happens on the receiver side.
The relay is intentionally one-way per pair: devices flow from transmitter to receiver, not back. The interfaces enforce direction (Assembly-CSharp.dll decompile lines 364740-364751 and 365149-365158):
public interface ITransmitDataNetworkDevices : ILogicable, IReferencable, IEvaluable
{
List<IReceiveDataNetworkDevices> ConnectedDataNetReceivers { get; set; }
CableNetwork DataCableNetwork { get; }
bool DataConnectionActive();
void RemoveReceiver(IReceiveDataNetworkDevices receiver);
void AddReceiver(IReceiveDataNetworkDevices receiver);
}
public interface IReceiveDataNetworkDevices : ILogicable, IReferencable, IEvaluable
{
ITransmitDataNetworkDevices ConnectedDataNetTransmitter { get; set; }
CableNetwork DataCableNetwork { get; }
bool DataConnectionActive();
bool GetIsOperable();
}
Vanilla implementers (decompile lines 364757, 365159): RocketDataDownLink is the transmitter, RocketDataUpLink is the receiver. The two are bound by configured device IDs, and the resulting relay makes the rocket-side device list readable from a station-side IC10 chip.
Note that the remote devices are added by reference. They still own their original DataCables pointing into their home network. Adding them to a foreign _dataDeviceList only means a logic walker iterating that list will find them; their internal state is unchanged. Logic reads and writes go through the standard ILogicable methods on the device, which do not care which network the iteration started from.
A mod that wants a device to appear logic-transparent (logic flows through it even though it splits the power network) has two implementation choices:
- Implement both
ITransmitDataNetworkDevicesandIReceiveDataNetworkDeviceson the bridging device and configure the two sides as a bound pair (input side as receiver pointing at output side as transmitter, and the inverse), so the standard relay merges each side into the other's data list. - Patch
RefreshPowerAndDataDeviceListsto, for a chosen device class, pull the other side'sDeviceListinto the local_dataDeviceListdirectly. This skips the interface dance for devices that are always bound to themselves (a transformer's input is always paired with its own output for data purposes).
Field shape and accessor quirk¶
The backing lists are declared protected readonly, which is reachable from a Harmony patch via field-injection by name (___dataDeviceList / ___powerDeviceList). readonly only fixes the list reference; .Add / .Remove are still valid mutations. Verbatim, from decompile lines 270619-270627 at 0.2.6403.27689:
public readonly List<Device> DeviceList = new List<Device>();
protected readonly List<Device> _dataDeviceList = new List<Device>();
protected readonly List<Device> _powerDeviceList = new List<Device>();
protected bool PowerDeviceListDirty;
protected bool DataDeviceListDirty;
The public property accessors (decompile lines 270678-270700) refresh on read:
public List<Device> DataDeviceList
{
get
{
if (PowerDeviceListDirty) // checks the POWER flag, not the data flag
RefreshPowerAndDataDeviceLists();
return _dataDeviceList;
}
}
public List<Device> PowerDeviceList
{
get
{
if (PowerDeviceListDirty || DataDeviceListDirty)
RefreshPowerAndDataDeviceLists();
return _powerDeviceList;
}
}
Both accessors return the underlying list reference (no copy), so mutations through the property are persistent.
Note the asymmetry: DataDeviceList.get refreshes when PowerDeviceListDirty is true and ignores DataDeviceListDirty. PowerDeviceList.get checks both flags. This is almost certainly a copy-paste leftover; in practice DirtyDataDeviceList() (which sets only the data flag) followed by a DataDeviceList read will return stale data unless something else (cable add/remove, power dirty) has happened to also set the power flag. Code that needs a fresh data list after a DirtyDataDeviceList() call should call RefreshPowerAndDataDeviceLists() explicitly. See Open Questions for the unresolved "is this a bug or a deliberate optimization" question.
Lifecycle: three constructors + pool registration + multiplayer sync¶
CableNetwork is tracked in ConcurrentDensePool<CableNetwork> AllCableNetworks = new ConcurrentDensePool<CableNetwork>("AllCableNetworks", 4096) (line 270588). The pool is the canonical "every network in the world" list. Instances are NOT acquired/returned from the pool via Get/Release in the call sites; the new CableNetwork(...) expressions seen below allocate fresh objects which then register themselves with AllCableNetworks via AssignReference (270854-270869, which also appends the network to the NewToSend SyncList when a server has clients). So each new CableNetwork(...) does go through one of three constructors, and Harmony postfixes on the constructors fire reliably.
The three constructors (lines 270893-270910):
public CableNetwork()
{
AssignReference(this, 0L); // 0L = "assign a fresh ReferenceId from the global counter"
CableNetworkType = CableNetworkType.CableNetwork;
}
public CableNetwork(long cableNetworkId)
{
AssignReference(this, cableNetworkId); // use the explicit id; do NOT increment the counter
CableNetworkType = CableNetworkType.CableNetwork;
}
public CableNetwork(Cable cable)
{
AssignReference(this, 0L); // fresh ReferenceId
CableNetworkType = CableNetworkType.CableNetwork;
Add(cable); // attaches the seed cable
}
REMOVED at 0.2.6403.27689: the static CableNetwork.OnNetworkChanged event is gone, with no replacement. The constructors above and Add(Cable) (271053-271088) no longer raise anything; the only OnNetworkChanged symbols left in the assembly are Networks.StructureNetwork's protected virtual INSTANCE method (189082 and its overrides), which is unrelated to CableNetwork. A mod that relied on the old static event as a "network created / cable added" signal must postfix the three constructors plus Add(Cable) instead. (The per-Cable instance event Cable.OnPowerNetworkChanged, raised by the Cable.CableNetwork setter, still exists; see Cable.)
Server vs client constructor usage:
- Server creates new networks via
new CableNetwork()ornew CableNetwork(cable). Both callAssignReference(this, 0L), which generates a fresh ReferenceId from the global counter. Example call site:Cable.OnRegistered(392523-392538) when a placed cable opens a new network island. - Client recreates networks via
new CableNetwork(referenceId)fromDeserializeNew(RocketBinaryReader)(line 271326). The factory reads the id and type from the wire and constructs eithernew CableNetwork(referenceId)ornew WirelessNetwork(referenceId). This is theSyncList<CableNetwork> NewToSend = new SyncList<CableNetwork>(DeserializeNew)(line 270650) factory. - Both sides recreate from saved id via the
(long)constructor: inCable.DeserializeSave(392427-392434),(Referencable.Find<CableNetwork>(cableNetworkId) ?? new CableNetwork(cableNetworkId)).Add(this)-- if a cable carries a deserialisedcableNetworkIdand noCableNetworkwith that id is found, one is constructed with the saved id rather than a fresh one. Used during save load and during the join-timeAddchain.
The discard pattern still applies: new CableNetwork(referenceId) allocates and the result is thrown away; AssignReference inside the constructor registers the instance so subsequent Referencable.Find<CableNetwork>(referenceId) lookups return it. The constructor's only externally-visible job on the client side is "register this id". (There is additionally a static join-time bulk path at 0.2.6403: CableNetwork.SerializeOnJoin / DeserializeOnJoin at 271288-271326 ship every valid network's id + type in one block.)
Pool / id-counter implications for mods:
- A Harmony postfix on any of the three
CableNetworkconstructors fires for both server-created and client-recreated networks. Posting state into the instance (e.g. injecting a replacementPowerTick) is therefore consistent on both sides. - The
(long)constructor is the ONLY path on a remote client that creates aCableNetwork. Anything that ends up on the client side that should know about a network must be reachable from this constructor postfix. - Cable burns destroy cables and can split networks on the server. Each split creates a new
CableNetwork()via the default constructor (fresh id from the counter). The server's id counter advances; clients only learn about the new id when the server replicates the split viaNewToSend.Send->DeserializeNew. If a server-side action (mod patch, voltage rule, anything that callscable.Break()server-only) creates an extra network between two replicated states, the server's id counter will be ahead of the client's expectation by one until the next sync delivers the new id. - The counter itself is per-runtime state, not derived from any synchronised seed. Server and client do NOT share a counter; the client relies entirely on the server-supplied
referenceIdfromDeserializeNew. An id "drift" between server and client of N means the server has created N more networks than the client has received messages for.
Membership mutators: Add, Remove, RebuildNetwork, RefreshNetwork, and the power-tick snapshot¶
The complete catalogue of code that mutates CableList membership, all main-thread in every vanilla call path found (Cable.OnRegistered, Cable.DeserializeSave, Cable.DeserializeOnJoin, Cable.OnDestroy, RebuildCableNetworkEvent apply, and the MultiMergeConstructor.Construct merge-absorb path which calls cable.CableNetwork?.Remove(cable)). The three constructors are in the Lifecycle section above; the instance and static Merge are verbatim in the Merge section below; ConnectedNetworks is verbatim there too. RemoveDevice(Device) (271011-271027) and RemoveDevice(Cable, Device) (271040-271051) are as previously documented, re-confirmed in place at 0.2.6403.27689.
CableNetwork.Add(Cable) (271053-271088), verbatim:
public void Add(Cable cable)
{
if (CableList.Contains(cable) || cable.CableNetwork == this)
{
return;
}
if (cable.CableNetwork != null)
{
cable.CableNetwork.Remove(cable);
}
cable.CableNetwork = this;
lock (CableList)
{
CableList.Add(cable);
}
CableFuse cableFuse = cable.SmallCell?.Device as CableFuse;
if ((bool)cableFuse)
{
cableFuse.CheckForCable();
}
Span<SmallCellRef> span = stackalloc SmallCellRef[32];
int count = 0;
cable.FillConnected<Device>(span, ref count);
Span<SmallCellRef> span2 = span;
Span<SmallCellRef> span3 = span2.Slice(0, count);
for (int i = 0; i < span3.Length; i++)
{
SmallCellRef smallCellRef = span3[i];
if (smallCellRef.TryGet<Device>(out var found))
{
AddDevice(cable, found);
found.FindDataCable();
}
}
cable.CableNetworkId = ReferenceId;
}
CableNetwork.Remove(Cable) (271090-271108), verbatim. NOTE the null-safety asymmetry against Add: Add reads cable.SmallCell?.Device (null-conditional), Remove reads cable.SmallCell.Device (plain dereference at 271102). Calling Remove on a cable that is not (or no longer) in a SmallCell throws NullReferenceException; a cable whose cell slot was merely overwritten by a stacked occupant keeps a stale but non-null SmallCell and does not trip it (see SmallCell, "Stacked pairs"):
public void Remove(Cable cable)
{
if (cable.CableNetwork != null && cable.CableNetwork == this)
{
cable.CableNetworkId = 0L;
cable.CableNetwork = null;
lock (CableList)
{
CableList.Remove(cable);
}
RefreshNetwork();
DirtyPowerAndDataDeviceLists();
CableFuse cableFuse = cable.SmallCell.Device as CableFuse;
if ((bool)cableFuse)
{
cableFuse.CheckForCable();
}
}
}
The rebuild BFS RebuildNetwork (271147-271201), verbatim (skips cables flagged IsBeingDestroyed):
private static void RebuildNetwork(Cable cable, CableNetwork newNetwork, CableNetwork oldNetwork)
{
Span<SmallCellRef> span = stackalloc SmallCellRef[32];
int count = 0;
cable.FillConnected<Cable>(span, ref count);
Queue<Cable> queue = new Queue<Cable>(count);
HashSet<Cable> hashSet = new HashSet<Cable>(oldNetwork?.CableList.Count ?? 16) { cable };
Span<SmallCellRef> span2 = span;
Span<SmallCellRef> span3 = span2.Slice(0, count);
for (int i = 0; i < span3.Length; i++)
{
SmallCellRef smallCellRef = span3[i];
Cable cable2 = smallCellRef.Get<Cable>();
if ((object)cable2 != null)
{
queue.Enqueue(cable2);
}
}
Span<SmallCellRef> span4 = stackalloc SmallCellRef[32];
while (queue.Count > 0)
{
Cable cable3 = queue.Dequeue();
if ((object)cable3 == null || hashSet.Contains(cable3) || cable3.IsBeingDestroyed)
{
continue;
}
hashSet.Add(cable3);
count = 0;
cable3.FillConnected<Cable>(span, ref count);
span2 = span;
span3 = span2.Slice(0, count);
for (int i = 0; i < span3.Length; i++)
{
SmallCellRef smallCellRef2 = span3[i];
Cable cable4 = smallCellRef2.Get<Cable>();
if ((object)cable4 != null && !hashSet.Contains(cable4))
{
queue.Enqueue(cable4);
}
}
if (oldNetwork != null)
{
int count2 = 0;
cable3.FillConnected<Device>(span4, ref count2);
span2 = span4;
span3 = span2.Slice(0, count2);
for (int i = 0; i < span3.Length; i++)
{
SmallCellRef smallCellRef3 = span3[i];
oldNetwork.RemoveDevice(cable3, smallCellRef3.Get<Device>());
}
}
newNetwork.Add(cable3);
}
}
RebuildCableNetworkServer / RebuildCableNetworkClient (271203-271234), verbatim (the server body is also excerpted with commentary in the split section below):
public static void RebuildCableNetworkServer(SmallCellRef cableRef)
{
RebuildCableNetworkServer(cableRef.Get<Cable>());
}
public static void RebuildCableNetworkServer(Cable cable)
{
if (GameManager.GameState != GameState.None)
{
CableNetwork cableNetwork = cable.CableNetwork;
CableNetwork cableNetwork2 = new CableNetwork(cable);
if (Assets.Scripts.Networking.NetworkManager.IsServer && NetworkServer.HasClients())
{
RebuildCableNetworkEvent.NewEvents.Add(new RebuildCableNetworkEvent(cable.ReferenceId, cableNetwork2.ReferenceId, cableNetwork.ReferenceId));
}
RebuildNetwork(cable, cableNetwork2, cableNetwork);
}
}
public static void RebuildCableNetworkClient(Cable cable, CableNetwork newNetwork, CableNetwork oldNetwork)
{
if (GameManager.GameState != GameState.None)
{
if ((object)cable == null || newNetwork == null)
{
ConsoleWindow.PrintError("Cable or Network null during rebuild");
return;
}
newNetwork.Add(cable);
RebuildNetwork(cable, newNetwork, oldNetwork);
}
}
RefreshNetwork (271236-271261), verbatim. This is the network-dissolution point: an empty (invalid) network deregisters itself and drops every device:
protected void RefreshNetwork()
{
if (!IsNetworkValid() && GameManager.GameState != GameState.None && !InUseWithEvent)
{
Referencable.Deregister(this);
AllCableNetworks.Remove(this);
HelperHintsManager.Deregister(this);
{
foreach (Device item in new List<Device>(DeviceList))
{
RemoveDevice(item);
}
return;
}
}
for (int num = CableList.Count - 1; num > -1; num--)
{
if (CableList[num] == null)
{
lock (CableList)
{
CableList.RemoveAt(num);
}
}
}
}
Why main-thread membership churn mid-tick is tolerated: the power tick reads membership from the UniTask ThreadPool worker, but only via a per-tick snapshot. PowerTick.Initialise(CableNetwork) (271742-271763) copies PowerDeviceList, FuseList, and CableList into its own lists under lock (CableNetwork.CableList) (and the other two locks) each tick; main-thread Add / Remove also lock CableList. So membership mutation on the main thread is coordinated with the worker snapshot, and the worker tolerates destroyed members afterwards via cable == null Unity fake-null checks (GetBreakableCables 271869-271879). Verbatim:
public void Initialise(CableNetwork cableNetwork) // line 271742
{
CableNetwork = cableNetwork;
Devices.Clear();
Fuses.Clear();
Cables.Clear();
lock (CableNetwork.PowerDeviceList)
{
Devices.AddRange(CableNetwork.PowerDeviceList);
}
lock (CableNetwork.FuseList)
{
Fuses.AddRange(CableNetwork.FuseList);
}
lock (CableNetwork.CableList)
{
Cables.AddRange(CableNetwork.CableList);
}
Potential = 0f;
Required = 0f;
Consumed = 0f;
}
Hook hazard: a Harmony prefix that skips Add(Cable) to refuse membership breaks Merge's invariants (the old network is unconditionally Clear()ed and RefreshNetwork()ed, leaving the skipped cable with a dangling network reference and a stale CableNetworkId, invisible to AllCableNetworks), and any path that reaches Remove for a cell-less cable hits the 271102 NullReferenceException. The safe refusal point is a Cable.OnRegistered postfix; the full interception-point analysis is on Cable, "Registration-time guard".
Merge: cableNetworks[0] wins, ConnectedNetworks defines the order¶
When a placed cable bridges two or more existing networks, the surviving network is determined entirely by list order. The static factory at line 271129 (shape identical at 0.2.6403.27689):
public static CableNetwork Merge(List<CableNetwork> cableNetworks)
{
if (cableNetworks.Count <= 0) return null;
if (cableNetworks.Count == 1) return cableNetworks[0];
CableNetwork cableNetwork = cableNetworks[0];
for (int i = 1; i < cableNetworks.Count; i++)
cableNetwork.Merge(cableNetworks[i]);
return cableNetwork;
}
cableNetworks[0] is the survivor; every other network in the list is consumed via the instance-level Merge (271110-271127):
public void Merge(CableNetwork oldNetwork)
{
if (oldNetwork == null || oldNetwork == this) return;
for (int num = oldNetwork.CableList.Count - 1; num >= 0; num--)
{
Cable cable = oldNetwork.CableList[num];
if ((object)cable != null) Add(cable);
}
oldNetwork.CableList.Clear();
oldNetwork.RefreshNetwork();
oldNetwork.DirtyPowerAndDataDeviceLists();
}
The instance Merge walks oldNetwork.CableList in reverse and reassigns each cable into this via Add. The old network's cable list is cleared, its RefreshNetwork runs to flush internal state, and both lists' dirty flags are set.
ConnectedNetworks is where the order is established (line 271268; at 0.2.6403.27689 it is FillConnected-based, superseding the removed ConnectedCables() version):
public static List<CableNetwork> ConnectedNetworks(Cable cable)
{
Span<SmallCellRef> span = stackalloc SmallCellRef[32];
int count = 0;
cable.FillConnected<Cable>(span, ref count);
List<CableNetwork> list = new List<CableNetwork>(count);
Span<SmallCellRef> span2 = span;
Span<SmallCellRef> span3 = span2.Slice(0, count);
for (int i = 0; i < span3.Length; i++)
{
SmallCellRef smallCellRef = span3[i];
Cable cable2 = smallCellRef.Get<Cable>();
if (!list.Contains(cable2.CableNetwork))
{
list.Add(cable2.CableNetwork);
}
}
return list;
}
So the order of networks in the merge list is the order of unique CableNetwork references encountered while iterating the FillConnected<Cable> hits, which follow the cable's OpenEnds declaration order (see the FillConnected walk below). Two cables that belong to the same network are deduplicated; the first cable from each unique network "claims" the slot. The list is a fresh per-call allocation and the span is stackalloc, so this path no longer touches any shared static buffer.
Multiplayer-relevance implication. Both server and client run the merge independently on their own side. Cable.OnRegistered runs on both sides (cable placement replicates), but its merge call is server-only via the GameManager.RunSimulation gate (392525 at 0.2.6403.27689). The client's runtime merge happens in Cable.DeserializeOnJoin (392451-392462):
public override void DeserializeOnJoin(RocketBinaryReader reader)
{
base.DeserializeOnJoin(reader);
CableNetworkId = reader.ReadInt64();
CableNetwork cableNetwork = Referencable.Find<CableNetwork>(CableNetworkId);
CableNetwork cableNetwork2 = cableNetwork;
if (GameManager.GameState != GameState.Joining)
{
cableNetwork2 = CableNetwork.Merge(CableNetwork.ConnectedNetworks(this)) ?? cableNetwork;
}
cableNetwork2.Add(this);
}
DeserializeOnJoin here is the per-cable wire deserialise. The !Joining gate means: during initial join, accept the server's network id verbatim; during normal runtime, re-derive the merge survivor locally via ConnectedNetworks. Both sides therefore run their OWN Merge(ConnectedNetworks(cable)) on the placed cable. If the FillConnected<Cable> walk returns adjacent cables in a different order on server vs client at the moment of merge, the chosen survivors differ. The two pre-merge ids both still resolve to live CableNetwork instances on the side that did NOT consume them (Merge only destroys the non-survivors LOCALLY on each side), producing a stable "server sees id X, client sees id Y" desync that persists indefinitely until a future merge reconciles via a different cable registration.
FillConnected iteration order (0.2.6403.27689; supersedes the removed ConnectedCables() / FoundCables walk). SmallGrid.FillConnected<T>(Span<SmallCellRef> buf, ref int count) (line 312804; the NetworkType-filtered overload at 312896 and the untyped one at 312852 share the shape) walks OpenEnds in declaration order; for each open end it projects openEnd.Transform.position through WorldToLocalGrid, fetches the SmallCell, and appends a SmallCellRef for EACH occupant slot (checked in the fixed order Cable, Chute, Device, Pipe, Rail, Other) that is a T, is not this, and agrees via IsConnected(openEnd). Key facts:
- The buffers are caller-provided
stackalloc Span<SmallCellRef>(BUFFER_SIZE = 32,SmallCellRefstruct at 290601). There is NO shared static buffer any more; the oldFoundCablescorruption hazard is gone from the game. - The result order is still the
OpenEndsiteration order (with the per-cell occupant-slot order as a secondary key when one cell holds multiple matching occupants);OpenEndsis prefab-serialised and identical on both sides for the same prefab orientation. SmallCellRefstores only aGrid3+SmallCellTypeand re-resolves lazily viaGet<T>()againstGridController.World.GetSmallCell, so refs can go stale (the occupant leaves the cell) andGet<T>()then returns null.
So under vanilla mechanics, two consecutive runs of the walk on the same cable from the same logical game-state point should produce identical orderings on both server and client. Any divergence implies one of:
- A different physical OpenEnds list (different cable prefab rotation/orientation on the two sides, which would also cause many other visible discrepancies -- unlikely without a much louder symptom).
- A different
smallCell.Cableat the same grid position on the two sides (e.g. one side has already updated the cell to point at the new cable, the other hasn't). - A different return value from
IsConnected(openEnd)for one of the neighbours (e.g. that neighbour's network/state hasn't been refreshed on one side).
Thread-safety is unchanged in the one way that matters: every FillConnected overload still reads openEnd.Transform.position (lines 312808 / 312856 / 312904), a main-thread-only Unity Transform getter, so calling it from the power-tick worker thread (ThreadedManager.IsThread true) remains unsafe. The game's off-thread idiom is the cached grid: Connection.Initialize() early-returns without touching the Transform when IsThread (311918-311932), and Connection.GetLocalGrid() (312009-312016) returns the cached LocalGrid once initialized. This coupling is why vanilla's split BFS RebuildNetwork (which runs FillConnected per dequeued cable) cannot be invoked from the worker thread to make a cable burn's network split land in the same tick; see Cable, "Network split on destruction". (Power Grid Plus's VoltageTier.HasHigherTierNeighbour try/catch note predates the API migration; the removed ConnectedCables(NetworkType) it wrapped no longer exists and the mod needs the replacement pattern from CursorAdjacencyLookup.)
Calls inside Add() that re-enter the cable graph (decompile lines 271053-271088): Add(Cable) runs its own stackalloc + cable.FillConnected<Device>(span, ref count) (271073-271075), then per found device calls AddDevice(cable, found) and found.FindDataCable() (271083-271084) before writing cable.CableNetworkId = ReferenceId (271087). Device.FindDataCable() (371568) and FindPowerCable() (371588) each use their own stackalloc span, so nested walks cannot clobber the outer caller's buffer; each nesting level owns its stack frame.
RebuildCableNetworkServer (line 271208, plus a SmallCellRef convenience overload at 271203 used by Cable.OnDestroy) is explicitly server-only by name. RebuildNetwork (private static, line 271147) is the BFS used to re-fold cables into a target network after a split or a forced rebuild; at 0.2.6403 it is FillConnected-based, skips cables flagged IsBeingDestroyed (271169), and is otherwise shape-identical to the 0.2.6228 version. Neither raises any event (the old OnNetworkChanged calls are gone with the event).
Split is authoritative; merge is not. Asymmetric multiplayer sync¶
The split (destruction / fuse-break) and merge (placement) paths sync differently across the wire. This is the structural reason a cable-id desync can persist across many ticks.
Split path (authoritative, ids synced) -- RebuildCableNetworkServer (line 271208; body shape identical at 0.2.6403.27689):
public static void RebuildCableNetworkServer(Cable cable)
{
if (GameManager.GameState != GameState.None)
{
CableNetwork cableNetwork = cable.CableNetwork; // old (will be split)
CableNetwork cableNetwork2 = new CableNetwork(cable); // new (server-allocated id)
if (Assets.Scripts.Networking.NetworkManager.IsServer && NetworkServer.HasClients())
{
RebuildCableNetworkEvent.NewEvents.Add(
new RebuildCableNetworkEvent(
cable.ReferenceId,
cableNetwork2.ReferenceId, // new id
cableNetwork.ReferenceId)); // old id
}
RebuildNetwork(cable, cableNetwork2, cableNetwork);
}
}
The server allocates new CableNetwork(cable) (which increments the server-only ReferenceId counter) and emits RebuildCableNetworkEvent carrying both the new and old ids. The client receives this event and runs RebuildCableNetworkClient(cable, newNetwork, oldNetwork) (line 271222) which delegates to the same RebuildNetwork BFS using the server's chosen ids. Splits cannot desync.
Merge path (each side decides independently, no event) -- Cable.OnRegistered (line 392523) on the server runs:
public override void OnRegistered(Cell cell)
{
if (GameManager.GameState != GameState.Loading && GameManager.RunSimulation)
{
CableNetwork cableNetwork = CableNetwork.Merge(CableNetwork.ConnectedNetworks(this));
if (cableNetwork != null) cableNetwork.Add(this);
else new CableNetwork(this);
}
base.OnRegistered(cell);
}
and the client runs Cable.DeserializeOnJoin (line 392451), already excerpted above. No RebuildCableNetworkEvent-equivalent fires for merges. The server's chosen survivor is implicitly carried in the cable's CableNetworkId field on the wire, but the client discards it in DeserializeOnJoin whenever GameManager.GameState != GameState.Joining and instead recomputes Merge(ConnectedNetworks(this)) locally. The local recomputation is what desyncs.
RebuildCableNetworkEvent wire shape and client apply path (decompile line 272397 at 0.2.6403.27689; the excerpt below is the 0.2.6228 body, shape re-confirmed by the declaration and the unchanged RebuildCableNetworkClient consumer):
public readonly struct RebuildCableNetworkEvent(long originNetworkedStructureReference, long newNetworkReference, long oldNetworkReference) : ISyncListable
{
public readonly long OriginNetworkedStructureReference = ...;
public readonly long NewNetworkReference = ...;
public readonly long OldNetworkReference = ...;
public static SyncList<RebuildCableNetworkEvent> NewEvents = new SyncList<RebuildCableNetworkEvent>(DeserializeEvent);
public void Serialize(RocketBinaryWriter writer) {
Network.WritePackedId(writer, OriginNetworkedStructureReference);
Network.WritePackedId(writer, NewNetworkReference);
Network.WritePackedId(writer, OldNetworkReference);
}
private static void DeserializeEvent(RocketBinaryReader reader) {
Network.ReadPackedId(reader, out var referenceId);
Network.ReadPackedId(reader, out var referenceId2);
Network.ReadPackedId(reader, out var referenceId3);
Cable cable = Referencable.Find<Cable>(referenceId);
CableNetwork newNetwork = Referencable.Find<CableNetwork>(referenceId2);
CableNetwork oldNetwork = Referencable.Find<CableNetwork>(referenceId3);
CableNetwork.RebuildCableNetworkClient(cable, newNetwork, oldNetwork);
}
}
The client Referencable.Find<CableNetwork>(referenceId2) lookup for the NEW network depends on that network having been previously delivered via SyncList<CableNetwork> NewToSend. If the SyncList ordering puts the rebuild event before the network creation, newNetwork is null and RebuildCableNetworkClient exits early with ConsoleWindow.PrintError("Cable or Network null during rebuild") (line 271228). Same hazard applies to cable (must arrive via thing-sync first) and oldNetwork (must still be alive). The serializer (RebuildCableNetworkEvent.NewEvents.Serialize(writer), 0.2.6228 line 197579, not re-located this pass) is part of the per-tick MessageFactory packet, so all three syncs (NewToSend, thing-sync, RebuildEvent) ride the same packet -- but their per-stream order is what determines whether the client's Referencable.Find succeeds when the event applies.
Vanilla survivability vs mod-driven desync. Under vanilla mechanics the iteration is expected to match: same OpenEnds, same SmallCells (network state is deterministic), same IsConnected results, same FoundCables (the static reusable list is only the game thread's caller on vanilla since the power tick runs server-side only and even the host does not run merges on a worker). So vanilla mostly "gets lucky" with deterministic ordering -- the structural bug is latent. Once a mod perturbs anything that touches the moment-of-merge state of an adjacent cable's network reference, or causes an additional construction event whose ordering differs between server and client, the latent bug surfaces and stays stuck: each side has already picked its winner and the loser network instance is still alive on the OTHER side, so no future tick alone reconciles them.
Resolution: deterministic Merge sort makes the survivor independent of ConnectedCables order¶
The structural fragility above is closed by a Harmony prefix that sorts the input list to Merge(List<CableNetwork>) by ReferenceId ascending before vanilla picks list[0]. The same fix applies to the parallel StructureNetwork.Merge(List<StructureNetwork>, out StructureNetwork) overload (decompile line 177412), which has the identical "list[0] wins" shape and is reached by RocketNetwork, RoboticArmNetwork, LandingPadNetwork, and (via the StructureNetwork base class) any other static-network type that has an INetworkedStructure.DeserializeOnJoin that re-runs a local merge. WirelessNetwork inherits from CableNetwork and so is covered by the CableNetwork.Merge patch.
ReferenceIds are server-allocated and identical on every peer for the same network instance (see "Lifecycle: three constructors" above). Sorting by them makes survivor selection a pure function of the network identifiers rather than ConnectedCables iteration order, so server and client converge on the same survivor regardless of whether OpenEnds ordering, smallCell.Cable state, or any other adjacency input differs between the two sides.
Both Merge(List) overloads only iterate the input list (they never mutate it), and every call site passes a fresh ConnectedNetworks(thing) allocation (CableNetwork.ConnectedNetworks line 254113, StructureNetwork.ConnectedNetworks line 177115 -- both allocate a new List<> per call), so in-place sort inside the prefix is safe. The instance Merge(other) is the recursive consumer and does not call back to the static Merge(List), so a Harmony prefix on the static method does not re-enter.
The fix lives in Power Grid Plus as Patches/MergeDeterminismPatches.cs. It is independent of the upstream cause of any ordering divergence: even if some future mod or vanilla code path causes ConnectedCables to disagree across the wire, the merge id-level outcome stays in sync. The cosmetic rotation of the placed cable may still differ visually between sides if the upstream cause is in cable-rotation handling, but the CableNetwork.ReferenceId (and therefore power-tick / logic-tick membership) converges.
Survivor-selection rule change: vanilla picked whichever network's first cable was first in ConnectedCables iteration order, which was usually-but-not-always the larger/older network. The new rule picks the lowest ReferenceId. Older networks have lower ids, so in steady-state play the rule tends to preserve the same survivor vanilla would have picked under non-divergent conditions. In the smoking-gun reproduction the server picked 386495 (6 cables, higher id) and the client picked 386494 (269 cables, lower id); after the fix both sides pick 386494 -- the lower id, which was also the larger of the two pre-merge networks.
OnRegistered order-of-operations (corrected 2026-07-14). Inside Cable.OnRegistered (392523-392538) the merge call does precede base.OnRegistered(cell); that sub-fact stands. But the SmallCell write is NOT in the OnRegistered chain: GridController.AddSmallGridStructure writes smallCell.Add(smallGridObject) and the SmallCell back-pointer (decompile 206876-206887; the SmallCell.Add occupant write is 293100-293106) BEFORE invoking OnRegistered(null) (206899), and nothing in the OnRegistered chain (SmallGrid.OnRegistered 312289-312351, Structure.OnRegistered 315513-315560, Thing.OnRegistered 319758-319773) writes SmallCell state. So at the moment of ConnectedNetworks(this) the new cable IS already in its own SmallCell. The observable merge behavior is unchanged because FillConnected excludes this explicitly and walks only the cells faced by OpenEnds, never the cable's own cell. The same holds on the client's DeserializeOnJoin path: the cell writes happened inside Thing.Create (RegisterAs -> OnAssignedReference -> Register -> AddSmallGridStructure) before DeserializeOnJoin runs, so by the time cableNetwork2.Add(this) executes the cable has long been in its own SmallCell on both sides. Full chain on StructureRegistration.
SmallGrid.IsConnected(Connection) semantics (line 294154, two overloads at 294154 and 294171):
public virtual bool IsConnected(Connection otherEnd)
{
Grid3 facingGrid = otherEnd.GetFacingGrid();
foreach (Connection openEnd in OpenEnds)
{
if ((openEnd.ConnectionType & otherEnd.ConnectionType) != NetworkType.None)
{
Grid3 localGrid = openEnd.GetLocalGrid();
if (facingGrid == localGrid) return true;
}
}
return false;
}
A neighbour is considered "connected" if any of its OpenEnds shares a NetworkType bit with the caller's OpenEnd AND its local grid equals the caller's facing grid. The bitwise & means a Data-only connection and a Power-only connection do not pass the filter; a Power+Data cable connecting to a Power+Data port does. Both overloads short-circuit on the first match; only the second returns the actual connectedEnd. Since the result depends solely on the neighbour cable's OpenEnds and the caller's otherEnd -- both prefab-determined transform state -- this filter is deterministic across server and client as long as cable orientation matches.
Verification history¶
- 2026-07-15: live re-confirmation of the Merge section's survivorship mechanics on the dedicated server (0.2.6403.27689) during the PowerGridPlus fresh-device investigation: a bridging cable placed between an isolated stub network and a powered trunk merged with
cableNetworks[0]surviving exactly as documented, and flipping the bridge cable 180 degrees (which reordersConnectedNetworksvia the OpenEnds walk) confirmed the ordering source (under Power Grid Plus's deterministic merge sort the lowest ReferenceId survived in both orientations, as that patch intends). Also re-confirmed live thatRebuildCableNetworkServerallocates a fresh network ReferenceId (new CableNetwork(cable)), so a trunk's id is only as old as its last cut, which makes an older stub network the deterministic merge survivor under the sorted order. No content change; Merge section restamped. - 2026-07-14: conflict on "OnRegistered SmallCell write ordering". Previous claim: the base.OnRegistered chain writes smallCell.Cable after the merge, so the new cable is not yet in its own SmallCell during ConnectedNetworks. New finding: GridController.AddSmallGridStructure writes the cell occupant and back-pointer before invoking OnRegistered(null); nothing in the OnRegistered chain writes SmallCell. Fresh validator verdict: B is correct (one sub-fact of the old claim stands: the merge precedes base.OnRegistered inside Cable.OnRegistered). Result: the paragraph is rewritten to the AddSmallGridStructure-first mechanism with 0.2.6403.27689 decompile line citations; section restamped.
- 2026-07-14: added the "Membership mutators: Add, Remove, RebuildNetwork, RefreshNetwork, and the power-tick snapshot" section from the mixed-tier cable network guard research pass against the 0.2.6403.27689 decompile:
Add(Cable)verbatim (271053-271088),Remove(Cable)verbatim (271090-271108) with the unguardedcable.SmallCell.Devicedereference at 271102 (NRE hazard for cell-less cables;Adduses the null-conditional),RebuildNetworkBFS verbatim (271147-271201, skipsIsBeingDestroyed),RebuildCableNetworkServer/RebuildCableNetworkClientverbatim (271203-271234),RefreshNetworkdissolution verbatim (271236-271261),PowerTick.Initialiseper-tick snapshot underlock (CableList)verbatim (271742-271763; worker tolerates destroyed members via fake-null checks inGetBreakableCables271869-271879), the main-thread call-path census for all mutators, and theAdd-prefix refusal hazard (a skipped cable keeps a dangling reference to the unconditionally cleared old network). Additive;RemoveDeviceoverloads (271011-271027 / 271040-271051) re-confirmed in place. Cross-links Cable "Registration-time guard" and SmallCell. - 2026-07-02 (later): re-verified and restamped the "Data device list" and "HandleDataNetTransmissionDevice" sections against the 0.2.6403.27689 decompile (
RefreshPowerAndDataDeviceLists270746-270787 with theCable[]array iteration replacing the oldList<Cable>foreach;HandleDataNetTransmissionDevice270789-270814 shape-identical;DirtyPowerAndDataDeviceLists/DirtyDataDeviceList270816-270825). Added two consequences to the data-device-list section: power-tick membership requires at least oneDevice.PowerCablesentry pointing into this network (the 270772-270783 filter; aDeviceListmember without one is invisible to this network's PowerTick), and theWirelessNetwork.RefreshPowerAndDataDeviceListsoverride (272486-272492) takes the wholeDeviceListas the power list with a permanently empty data list (cross-linked to PowerTransmitter). Additive plus excerpt refresh; no prior claim contradicted. Driving work: Powered-semantics stage of the power rearchitecture session. - 2026-07-02: grid-adjacency API migration + re-verification pass against the 0.2.6403.27689 decompile after the game update from 0.2.6228.27061. SUPERSEDED: (a)
SmallGrid.ConnectedCables()(both overloads) and the staticFoundCablesbuffer are REMOVED from the game;ConnectedNetworks(271268) is nowFillConnected<Cable>-based overstackalloc Span<SmallCellRef>(verbatim replaced), the "ConnectedCables iteration order" block is rewritten as "FillConnected iteration order" (walk 312804 / 312852 / 312896; still OpenEnds order; per-cell occupant-slot order Cable/Chute/Device/Pipe/Rail/Other as secondary;Transform.positionmain-thread coupling unchanged at 312808 / 312856 / 312904; shared-buffer corruption hazard gone), andAdd(Cable)'s device walk (271053-271088) now uses its own stackallocFillConnected<Device>. (b) the staticCableNetwork.OnNetworkChangedevent is REMOVED with no replacement: constructors (270893 / 270899 / 270905) andAdd(Cable)(271053) no longer raise anything; remainingOnNetworkChangedhits in the assembly areNetworks.StructureNetwork's protected virtual instance method (189082 area), unrelated; mods needing the old signal must postfix the three constructors plusAdd(Cable). © the class declaration gained logic surfaces:CableNetwork : IReferencable, IEvaluable, ILogicable, ISyncListable, IDensePoolable, IMemoryReadable, IMemory(270571). Re-verified unchanged with new refs: staticMergelist[0]-wins (271129), instanceMerge(271110-271127),RebuildNetworkBFS (271147, now FillConnected-based and skippingIsBeingDestroyed, otherwise shape-identical),RebuildCableNetworkServer(271208, newSmallCellRefoverload 271203),RebuildCableNetworkClient(271222, null-guard error at 271228),RemoveDevice(271011-271027, now invokingdevice.OnRemoveCableNetwork(this)at 271024, see PowerReceiver for the consequence), field block + accessor quirk (270619-270700; theDataDeviceList.get-checks-PowerDeviceListDirtyasymmetry persists),AssignReference/NewToSend(270854-270869 / 270650), constructors (270893-270910),DeserializeNew(271326, verbatim excerpt replaced by a summary pending a full re-read; decl and factory role confirmed),Cable.OnRegistered/DeserializeOnJoincall sites (392523 / 392451),RebuildCableNetworkEventdecl (272397). Sections not re-read this pass keep their 0.2.6228.27061 stamps: provider iteration / provider order (the bodies now live on PowerTick and were re-verified there), data-device-list refresh internals, refresh cadence call-site census, HandleDataNetTransmissionDevice, Network Analyser consumer, and the deterministic-merge-sort resolution section (its Power Grid Plus patch context is unchanged, but note itsConnectedCables-related caveats are now historical; the mod-side adjacency pattern is on CursorAdjacencyLookup). - 2026-05-02: page created. Sourced from a long-distance auto-aim test on the Lunar save: seven TX-RX pairs at 163-222 m all linked successfully (verified via InspectorPlus DishProbe), but only one RX showed
Powered=TrueandPowerProvided > 0. ReadingCableNetwork.ConsumePowerin Assembly-CSharp.dll (decompile lines 254579-254654) confirmed the single-supplier-first iteration, identifying the observed asymmetry as expected vanilla behaviour for parallel receivers on a shared destination network. - 2026-05-13: added "Data device list" and "HandleDataNetTransmissionDevice" sections, sourced from
Assembly-CSharp.dlldecompile lines 253589-253655 (refresh + relay) and 364740-365158 (interfaces and rocket-link implementers). Addedlogicandnetworktags. No conflict with the existing power-side content. Findings produced while researching whether transformers and APCs can be made logic-transparent for Power Grid Plus. - 2026-05-13: added "Field shape and accessor quirk" section, sourced from
Assembly-CSharp.dlldecompile lines 253460-253541. Notes theprotected readonlydeclarations (Harmony-reachable by name) and theDataDeviceList.getasymmetry that checksPowerDeviceListDirtyinstead ofDataDeviceListDirty. - 2026-05-15: added "Lifecycle: three constructors + pool registration + multiplayer sync" section, sourced from decompile lines 253430 (
ConcurrentDensePool<CableNetwork> AllCableNetworks), 253491 (SyncList<CableNetwork> NewToSend), 253735-253764 (the three constructors), 254162 (DeserializeNewfactory), 254055 (Cable.OnRegisterednew CableNetwork(cable)call site), and 371386 (Cable.Add(Referencable.Find<CableNetwork>(cableNetworkId) ?? new CableNetwork(cableNetworkId))). Documents the server-only ReferenceId counter, the client's exclusive use of the(long)constructor viaDeserializeNew, and the implication that mod-driven server-side network creation between replicated states advances the server's counter past the client's id horizon. - 2026-05-15: added "Merge:
cableNetworks[0]wins, ConnectedNetworks defines the order" section, sourced from decompile lines 253979 (instanceMerge), 253998 (staticMerge), 254110 (ConnectedNetworks), 254016 (privateRebuildNetwork), 254050 (RebuildCableNetworkServer), 371413 (Cable.OnRegisteredmerge call site). Documents that merge survivor is list-index-zero, that list order comes fromcable.ConnectedCables(), and the multiplayer implication that any perturbation ofConnectedCablesordering between server and client produces a stable id desync that persists across many ticks (both pre-merge ids stay live, one on each side). - 2026-05-15: expanded the multiplayer-relevance section with the verbatim
Cable.DeserializeOnJoinbody (line 371405). Documents thatCable.OnRegistered's merge is server-only (GameManager.RunSimulationgate) while the client's runtime merge happens inDeserializeOnJoin(gated byGameManager.GameState != GameState.Joining-- merge is skipped during the initial join handshake, runs during normal runtime updates). Both sides therefore run their ownMerge(ConnectedNetworks(cable))independently. Implications: a desync that depends onConnectedCablesordering between sides cannot be resolved by "the server is authoritative" because the client is making its own merge decision at runtime. - 2026-05-15: added the verbatim
SmallGrid.ConnectedCables()body (decompile line 294267) with notes onFoundCablesbeing a shared static reusable list, OpenEnds-iteration ordering, and the four mechanisms by which two sides could produce different orderings (different OpenEnds, differentsmallCell.Cable, differentIsConnectedresult, orFoundCablescorruption by a concurrent caller). Notes thatConnectedCables(NetworkType)at line 294319 is a different overload using a fresh per-call list. - 2026-05-15: added "Split is authoritative; merge is not. Asymmetric multiplayer sync" section, sourced from decompile lines 254050-254076 (
RebuildCableNetworkServerandRebuildCableNetworkClient), 371477-371491 (Cable.OnRegistered), 371405-371416 (Cable.DeserializeOnJoin), 294154-294188 (bothSmallGrid.IsConnected(Connection)overloads). Documents thatRebuildCableNetworkEventcarries the server's chosennewNetwork.ReferenceId+oldNetwork.ReferenceIdto the client (splits are id-authoritative), while merges have no equivalent event and the client always re-runsMerge(ConnectedNetworks(this))locally afterGameState != Joining. This is the structural source of a stable cable-network-id desync after a merge: each side's loser network instance is still alive on the OTHER side, so the discrepancy persists indefinitely. NotesCable.OnRegistered'sbase.OnRegistered(cell)-after-merge ordering means the new cable is not yet in its own SmallCell at the moment ofConnectedNetworks(this)on the server, while the equivalent guarantee for the client'sDeserializeOnJoinpath is not yet established. - 2026-05-15: added the verbatim
RebuildCableNetworkEventstruct +DeserializeEventbody (decompile line 255178-255205) inside the split-vs-merge section. Documents the wire shape (three packedlongids: origin cable, new network, old network), the per-tick serializer at line 197579, and the per-stream ordering hazard: the client'sReferencable.Find<CableNetwork>(NewNetworkReference)requires the matchingSyncList<CableNetwork> NewToSenddelivery to have already occurred, otherwiseRebuildCableNetworkClientearly-returns with aCable or Network null during rebuildconsole error (line 254070). - 2026-05-15: extended the ConnectedCables-iteration-order section with verification that the
Add()walk'scable.ConnectedDevices()anditem.FindDataCable()calls do NOT re-enter the staticFoundCables.Device.FindDataCable(decompile line 350763),Device.FindPowerCable(line 350778), andDevice.InitializeDataConnection(line 350794) all use theConnectedCables(NetworkType)overload (line 294319) which allocates a freshnew List<Cable>(4)per call. So the only path from insideMerge -> Addthat re-touches the cable graph is via the safe overload; the staticFoundCablescannot be reentrantly clobbered by the merge's own internal walks. - 2026-05-18: corrected the page-intro namespace claim.
CableNetworkis inAssets.Scripts.Networks(decompile lines 253403 / 253411), NOTAssets.Scripts.Objects.Electricalas previously stated. Sibling baseStructureNetworkis in a different top-level namespace called justNetworks(decompile lines 175608 / 177045). Confirmed by a Power Grid Plus build that neededusing Networks;separately fromusing Assets.Scripts.Networks;to resolve both types. - 2026-05-18: added "Resolution: deterministic Merge sort" section. Confirmed via the full decompile that the bug was vanilla-structural, not Power Grid Plus -- the original 2026-05-15 "disappears when Power Grid Plus is disabled" observation was symptomatic of Power Grid Plus's tier-burn mechanic generating enough cable destroy/place churn to expose the latent ordering bug, not a Power Grid Plus patch directly mutating cable state on the client. Reviewed all 15 Power Grid Plus patches, plus the NetworkPuristPlus and SprayPaintPlus patches that touch cable / glow state: every state-mutating path is either caller-gated on
GameManager.RunSimulation(host-only) or mode-gated on a thread-static set inside aRunSimulationbranch. None mutate cable rotation, OpenEnds, or network state on a client instance. The remaining open question is upstream-cause-of-rotation-divergence, narrowed below. Sourced from decompile lines 253998-254014 (CableNetwork.Merge(List)), 177412-177430 (StructureNetwork.Merge(List, out)), 254113 (CableNetwork.ConnectedNetworks), 177115 (StructureNetwork.ConnectedNetworks), 150889 / 162600 (INetworkedRocketPart/INetworkedRoboticArmDeserializeOnJoinclient-side local merge call sites). - 2026-06-13: extended the ConnectedCables-iteration-order note in the Merge section to record that the
ConnectedCables(NetworkType)overload (line 294319) ALSO readsopenEnd.Transform.position(line 294326), so it is buffer-safe (fresh list) but NOT worker-thread-safe -- the same main-thread-only Transform coupling as the parameterless overload. Documents that the game routes around this on the worker thread via cachedConnection.LocalGrid, that Power Grid Plus'sVoltageTier.HasHigherTierNeighbourwraps its call in atry/catchfor exactly this reason (degraded boundary targeting on the worker thread, not a crash), and that this coupling is why vanilla'sRebuildNetworksplit BFS cannot be invoked from the power-tick worker thread to land a split in-tick. Sourced from.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.cslines 294267-294335 (bothConnectedCablesoverloads verbatim). Produced while evaluating whether Power Grid Plus could make a cable burn's network split land in the same tick. Cross-links the new Cable "Network split on destruction" section. - 2026-05-21: added "Refresh cadence: device lists dirty only on structural change, never per tick" section. Enumerated all
DirtyPowerAndDataDeviceLists()/DirtyDataDeviceList()call sites from the decompile (lines 253652, 253849, 253858, 253892, 253966, 253995, 350798-350799, 364937, 365077, 365094, 365182, 365333): every one is a membership / connectivity / data-link-config event, none per-tick. Additive (no existing claim contradicted). Documents that a postfix onRefreshPowerAndDataDeviceListsruns at topology-change cadence and that transitive cross-network logic passthrough must propagate its own dirty. Produced while designing multi-hop logic passthrough for Power Grid Plus. - 2026-06-20: added "Consumer: the Network Analyser reads the raw
DeviceList, notDataDeviceList" section, sourced fromAssembly-CSharpdecompile lines 331171 (NetworkAnalyser : Cartridge), 331205 (GetScannedNetwork), and 331273-331317 (OnPreScreenUpdatereading_scannedNetwork.DeviceList.Countat 331287 and iterating_scannedNetwork.DeviceListat 331295). Additive; no existing claim contradicted. Documents that the analyser screen shows physicalDeviceListmembership only, so devices merged into_dataDeviceList(the rocket data-link relay, or a mod's logic-passthrough) do not appear there unlessNetworkAnalyser.OnPreScreenUpdateis also patched. Found while analyzing the third-party OmniLink mod, which pairs aDataDeviceList-getter postfix with aNetworkAnalyser.OnPreScreenUpdatetranspiler for exactly this reason.
Open questions¶
- Is the
DataDeviceList.getaccessor checkingPowerDeviceListDirtyinstead ofDataDeviceListDirtya vanilla bug or an intentional optimization tied to the fact that power dirties typically co-occur with data dirties? Recommend treating it as a bug and refreshing explicitly when only the data flag is set. - What in the multiplayer cable-spawn pipeline causes the client's instantiated cable rotation to be 180 degrees off the server's, when the player's cursor preview matched the server's eventual rotation? Reproduced 2026-05-15: cable rid=386513 lands at world position (-1292.0, 206.0, -780.0) with PowerDataConnection1 east / PowerDataConnection2 west on the server and the mirrored arrangement on the client (verified via
BUG2 CLT Cable.OnRegistered.PREOpenEnd dumps). The deterministic Merge sort fix above removes the consequence at the network-id level (both sides converge on the same survivor), but the underlying rotation desync still produces a cosmetic visual difference. The state-mutation audit completed 2026-05-18 ruled out Power Grid Plus, NetworkPuristPlus, and SprayPaintPlus as the cause. Candidates remaining: (1)Cable.SerializeOnJoin/Cable.DeserializeOnJoinnot preserving rotation faithfully when the join packet captures a cable that the server has already canonicalised but the per-tick bit-1 delta has not yet caught up to deliver -- the cable could arrive at the client at its pre-canonical rotation while the server's instance has already been re-rolled; (2) a third-party Workshop mod in the test session perturbing cable rotation on the client side; (3) latency-sensitive interaction between the bit-1 per-tick rotation delta and the SyncList delivery ordering of the new cable. Diagnostic next-step: log the server-sideCable.SerializeOnJoinrotation argument and the client-sideCable.DeserializeOnJoinrotation result for the same cable, see whether the rotation arrived correctly on the wire or whether it was already wrong at deserialisation time.