Skip to content

Structure grid snapping and network formation at placement

Why a Structure created programmatically at an arbitrary world position registers into the world but forms only a singleton network, while the same prefab placed through the build cursor joins its neighbours. The difference is one snapping call the programmatic path never makes.

Line numbers below index .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs (the client assembly) unless marked [ded], which indexes .work/decomp/0.2.6428.27798/Assembly-CSharp.DedicatedServer.decompiled.cs.

Three lattices, not one

Grid3 is not a cell index. It is a signed integer decimetre lattice (0.1 m per unit):

// Grid3(Vector3) :297425
public Grid3(Vector3 worldPosition)
{
    x = Round(worldPosition.x * 10f);
    y = Round(worldPosition.y * 10f);
    z = Round(worldPosition.z * 10f);
}

// Grid3.ToVector3() :297434
float num = (float)x / 10f; ...

// Grid3.Round :297483
public static int Round(float value)
{
    if (value >= 0f) { return (int)(value + 0.5f); }
    return (int)(value - 0.5f);
}

Corroborated by GridController.WorldToLargeGridCenter :211884, which builds a large-cell centre as Mathf.FloorToInt(worldPosition.x * (1f/2f)) * 20 + 10 : twenty Grid3 units per 2 m cell.

The second constructor snaps first, and this is the whole distinction:

// Grid3(Vector3, float gridBy, float gridOffset) :297468
public Grid3(Vector3 worldPosition, float gridBy, float gridOffset)
{
    worldPosition = worldPosition.GridCenter(gridBy, gridOffset);
    x = Round(worldPosition.x * 10f);
    y = Round(worldPosition.y * 10f);
    z = Round(worldPosition.z * 10f);
}
// Vector3.GridCenter(float, float) :236111
float num = gridSquareSize * 0.5f;
float num2 = offset + num;
worldPosition.x = Mathf.Round((worldPosition.x - num2) / gridSquareSize) * gridSquareSize + num2;
// y, z identically

The three lattices in play:

Lattice Size / offset Where it comes from Used by
Decimetre 0.1 m, no offset Grid3(Vector3) :297425 GridController.WorldToLocal :211876, ToGridPosition :236074
Large (structure) 2 m, offset 0 Structure.GridSize = 2f :319656, Structure.GridOffset :319659 (defaults) GridController.WorldToLocalGrid(pos) :211895 default args; GridBounds.GetLocalGrids :319468
Small 0.5 m, offset 0.25 SmallGrid.SmallGridSize = 0.5f :317953, SmallGrid.SmallGridOffset = 0.25f :317955 GridBounds.GetLocalSmallGrid :319473, SmallGrid.FillConnected :318719

GridCenter(0.5f, 0.25f) yields exact multiples of 0.5 m, so the small-cell lattice is phase-locked to world zero. GridCenter(2f, 0f) yields odd metres (1, 3, 5, ...).

GridSize and GridOffset are per-prefab serialized fields on Structure, not constants. The cursor code at :294523 discriminates on them (Math.Abs(smallGrid.GridSize - SmallGrid.SmallGridSize) < 0.1f), which is only meaningful if a cable prefab carries 0.5 where a wall carries 2.

The snapping call the build cursor makes

// Structure.GetWorldGrid(Vector3) :320781
public Vector3 GetWorldGrid(Vector3 position)
{
    if (_useAxisIndependentGridSnapping)
    {
        return base.GridController.ClampWorld(position, _gridSize3d, _gridOffset3d);
    }
    return base.GridController.ClampWorld(position, GridSize, GridOffset);
}

GridController.ClampWorld(pos, size, offset) :211906 is pos.GridCenter(size, offset). _useAxisIndependentGridSnapping :319664 and _gridSize3d :319668 cover prefabs whose per-axis sizes differ.

The build cursor calls it on every frame the placement preview moves:

InventoryManager cursor update
  :294216  _worldGrid = ConstructionCursor.GetWorldGrid(cameraForwardGrid + vector);
  :294270  _worldGrid = ConstructionCursor.GetWorldGrid(cameraForwardGrid);
  :294489  _worldGrid = ConstructionCursor.GetWorldGrid(cameraForwardGrid);
  :294227 / :294324 / :294371 / :294485 / :294517   _cursorPosition = <_worldGrid or a face of it>
  :294583  _usePrimaryPosition = _cursorPosition;
  :294801  OnServer.UseItemPrimary(Parent, ActiveHand.SlotId, _usePrimaryPosition,
                                   _usePrimaryRotation, ParentBrain.ClientId, SpawnPrefab);

OnServer.UseItemPrimary :40456 is a plain static:

public static void UseItemPrimary(Thing thing, int slotId, Vector3 targetLocation,
                                  Quaternion targetRotation, ulong steamId,
                                  ICreativeSpawnable spawnPrefab)
{
    Assets.Scripts.Objects.Item item = thing.Slots[slotId].Get<Assets.Scripts.Objects.Item>();
    if (item is AuthoringTool) { item = Prefab.Find<Constructor>(spawnPrefab.SpawnId); }
    item?.OnUsePrimary(targetLocation, targetRotation, steamId, authoringMode: false);
}

Constructor.OnUsePrimary :301151 then does Construct(targetLocation.ToGridPosition(), ...). ToGridPosition() is the decimetre constructor, which is lossless for an already-snapped value and does no snapping of its own. The only structure-aware snap in the whole chain is GetWorldGrid, and it happens in the cursor.

The programmatic path has no snap

CreateStructureInstance :286919:

public Grid3 LocalGrid;
public Quaternion LocalRotation;
public Structure Prefab;
public GridController GridController;
public int CustomColor = -1;
public ulong OwnerClientId;

public Vector3 WorldPosition => GridController.LocalToWorld(LocalGrid);   // :286931
public Quaternion WorldRotation => LocalRotation;                         // :286933

GridController.LocalToWorld(Grid3) :211901 is localGrid.ToVector3(), i.e. /10f. So CreateStructureInstance carries a decimetre-resolution position and preserves whatever it was given. It neither snaps nor validates.

Constructor.SpawnConstruct [ded]:287563:

public static Structure SpawnConstruct(CreateStructureInstance instance)
{
    if (GameManager.RunSimulation)
    {
        Structure structure = Thing.Create<Structure>(
            instance.Prefab, instance.WorldPosition, instance.WorldRotation, 0L);
        structure.SetStructureData(instance.LocalRotation, instance.OwnerClientId,
                                   instance.LocalGrid, instance.CustomColor);
        return structure;
    }
    if (Assets.Scripts.Networking.NetworkManager.IsClient)
    {
        new ConstructionCreationMessage(instance).SendToServer();
    }
    return null;
}

Structure.SetStructureData :321934 assigns fields only; RegisteredLocalGrid :319742 is a plain public field, so this is an assignment after the fact and re-registers nothing:

public void SetStructureData(Quaternion localRotation, ulong ownerClientId, Grid3 localGrid,
                             int customColourIndex)
{
    Direction = localRotation;
    base.OwnerClientId = ownerClientId;
    RegisteredLocalGrid = localGrid;
    if (customColourIndex >= 0 && PaintableMaterial != null && customColourIndex != CustomColor.Index)
    {
        SetCustomColor(customColourIndex);
    }
}

Consequence: a caller that builds a CreateStructureInstance from a raw world position (for example GridController.World.WorldToLocal(somePosition)) places the structure at that raw position, rounded only to 0.1 m. For a SmallGrid prefab (cable, pipe, chute, rail) that is up to 0.25 m off the lattice its own connectivity probe searches.

Registration, and where a network is formed

Registration is entered from Thing.Create, via the reference-assignment callback. The full chain (Thing.Create -> Referencable.RegisterNew/RegisterAs -> OnAssignedReference -> Register -> AddSmallGridStructure -> OnRegistered, all synchronous and all main-thread) is documented in StructureRegistration; repeated here only as far as the snapping question needs.

// Structure.OnAssignedReference() :320109
public override void OnAssignedReference()
{
    base.OnAssignedReference();
    Direction = ThingTransform.rotation;
    RegisteredLocalGrid = new Grid3(base.ThingTransformPosition);   // decimetre round only
    base.ThingTransformPosition = RegisteredLocalGrid.ToVector3();
    ... BlockingGrids from blockingGrids, rotated ...
    GridController.World.Register(this);                             // :320131
}

SmallGrid :317942 does not override OnAssignedReference; it overrides Awake, RebuildGridState, OnRegistered, OnDeregistered, WillJoinNetwork and the FillConnected family only.

// GridController.Register(Structure) :211949
public void Register(Structure structure)
{
    structure.ThingTransformPosition = LocalToWorld(structure.RegisteredLocalGrid);
    structure.RegisteredPosition = structure.ThingTransformPosition;
    structure.RegisteredRotation = structure.ThingTransformRotation;
    SmallGrid smallGrid = structure as SmallGrid;
    if ((bool)smallGrid)
    {
        AddSmallGridStructure(smallGrid);
        if (!smallGrid.DualRegister) { return; }
    }
    AddGridStructure(structure);
    AddFaceReference(structure);
    UpdateAirState(structure);
}

A pure SmallGrid (DualRegister == false, :317959) therefore never reaches AddGridStructure, and its OnRegistered is raised from the small-grid branch instead:

// GridController.AddSmallGridStructure(SmallGrid) :212300
Grid3[] array = (Grid3[])smallGridObject.GridBounds.GetLocalSmallGrid(
    smallGridObject.RegisteredPosition, smallGridObject.RegisteredRotation);
foreach (Grid3 grid in array) { /* SmallGridCells.Add(grid, smallCell); smallCell.Add(...) */ }
AllStructuresPool.Add(smallGridObject);
if (smallGridObject.IsUpdateOnServerTick) { AllServerTickStructures.Add(smallGridObject); }
foreach (Assets.Scripts.Objects.Connection openEnd in smallGridObject.OpenEnds)
{
    openEnd.CacheTransformUp();
}
smallGridObject.OnRegistered(null);
// then per-cell OnGridPlaced fan-out for Pipe / Cable / Device / Chute / Other / Rail / Owner
// GridBounds.GetLocalSmallGrid(Vector3, Quaternion) :319473
Vector3 worldPosition = rotation * grid.ToVector3() + position;
Grid3 grid2 = GridController.World.WorldToLocalGrid(worldPosition, 0.5f, 0.25f);

The network itself is formed in the leaf override. For cables, Cable.OnRegistered [ded]:199848 (Cable : SmallSingleGrid :399054):

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);
}

There is no separate "join the network" call to make. OnRegistered already runs for a programmatically spawned structure and already tries to merge. What it finds is the question, and that is decided entirely by the open-end probe:

// SmallGrid.FillConnected<T>(Span<SmallCellRef>, ref int) :318719
foreach (Connection openEnd in OpenEnds)
{
    Grid3 localGrid = base.GridController.WorldToLocalGrid(
        openEnd.Transform.position, SmallGridSize, SmallGridOffset);
    SmallCell smallCell = base.GridController.GetSmallCell(localGrid);
    if (smallCell != null)
    {
        if (IsValidConnection(smallCell.Cable, openEnd)) { buf[count++] = new SmallCellRef(smallCell, SmallCellType.Cable); }
        if (IsValidConnection(smallCell.Chute, openEnd)) { ... }
        if (IsValidConnection(smallCell.Device, openEnd)) { ... }
        if (IsValidConnection(smallCell.Pipe, openEnd)) { ... }
        if (IsValidConnection(smallCell.Rail, openEnd)) { ... }
        if (IsValidConnection(smallCell.Other, openEnd)) { ... }
    }
}
bool IsValidConnection(ISmallGrid sibling, Connection end)
{
    if (!(sibling is T val)) { return false; }
    if (val.ReferenceId != base.ReferenceId) { return val.IsConnected(end); }
    return false;
}

SmallGrid.OnRegistered :318206 (the base the leaf calls) then fans OnNeighborPlaced out over the same FillConnected result and OnGridAdjacentPlaced over GridController.PopulateSmallGridNeighbours, and carries a hard guard:

if (base.WorldGrid == WorldGrid.INVALID)
{
    throw new NullReferenceException("grid for " + DisplayName + " is invalid when registering");
}

Note the ordering inside Cable.OnRegistered: the merge runs before base.OnRegistered(cell) calls openEnd.Initialize() on each connection. AddSmallGridStructure has already called openEnd.CacheTransformUp() by then, so the probe has transform data, but Initialize has not run.

The one call a programmatic spawner must add

Structure.GetWorldGrid(Vector3) :320781, on the prefab, before building the CreateStructureInstance:

var prefab = Prefab.Find<Structure>(prefabName);
pos = prefab.GetWorldGrid(pos);                         // the missing snap
var grid = GridController.World.WorldToLocal(pos);      // decimetre, lossless on a snapped value
var instance = new CreateStructureInstance(prefab, grid, rotation, ownerClientId, colorIndex);
Constructor.SpawnConstruct(instance);

It is a public instance method on Structure, it reads the prefab's own GridSize / GridOffset (or the axis-independent pair), and it is exactly what the cursor calls. Calling CableNetwork.RebuildCableNetworkServer(cable) [ded]:263291 afterwards is not a substitute: that method also walks FillConnected, so it re-derives the same wrong answer from the same wrong geometry.

Snapping is necessary. Whether it is sufficient additionally depends on the rotation making the two open ends face each other and on the prefab variant being the right one (the cursor's SmartRotate cycle picks corner / junction variants; see PlacementOrientation.md). The open-end child transform offsets and the _gridsSmall bounds are Unity serialized asset data, not present in the assembly, so the exact cell a given prefab's open end probes cannot be read from a decompile.

Membership on load and on join comes from the saved id, not from geometry

Cable.OnRegistered is gated GameManager.GameState != GameState.Loading, so the geometric merge does not run during a world load. Membership is restored purely from the persisted id:

// Cable.DeserializeSave(ThingSaveData) :399154
public override void DeserializeSave(ThingSaveData savedData)
{
    base.DeserializeSave(savedData);
    if (savedData is CableSaveSaveData { CableNetworkId: var cableNetworkId })
    {
        (Referencable.Find<CableNetwork>(cableNetworkId) ?? new CableNetwork(cableNetworkId)).Add(this);
    }
}

Identically shaped on the other id-persisted networks:

Class Method Field
Pipe :390310 DeserializeSave :390787 PipeNetworkId :390297
Chute :373209 DeserializeSave :373310 ChuteNetworkId :373204
RoboticArmRailBase DeserializeSave :177190 RoboticArmNetworkId :177119
RoboticArmRailDeviceBase DeserializeSave :177338 RoboticArmNetworkId :177265

The join path agrees, and its GameState.Joining branch is the one that matters for a late joiner:

// Cable.DeserializeOnJoin(RocketBinaryReader) :399177
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);

So a world whose world.xml gives N cables the same CableNetworkId produces a real CableNetwork holding all N, with no geometric adjacency requirement whatsoever, and that membership propagates to a joining client. The same holds for pipes, chutes and robotic-arm rails. tools/save-edit/README.md currently states the opposite ("connections in vanilla code resolve via Cable.OnRegistered which walks adjacent cells... the Things must be geometrically adjacent (cells 2 units apart on each axis)"); that is true of runtime placement and false of the load path, and the "2 units" figure is the large-grid spacing, not the 0.5 m cable lattice.

ElevatorShaftNetwork is the exception among the listed networks: it carries no persisted id and is rebuilt from a shaft scan at :402250 (ShaftNetwork = ((this is ElevatorLevel) ? new ElevatorShaftNetwork(this as ElevatorLevel, CanCreateCarrage) : new ElevatorShaftNetwork(this));).

A save-loaded structure registers by the same chain, and its cells are COMPUTED

Everything above is about placement. The load path reaches the same registration code by a different door, and the difference matters for anyone editing world.xml by hand: nothing in a save records which cells a structure occupies. The set is recomputed from position, rotation and the prefab's baked GridBounds every time the structure registers.

The load entry point is XmlSaveLoad.Load<T> (the load methods live on XmlSaveLoad : ManagerBase, not on WorldManager):

public static T Load<T>(ThingSaveData thingData, bool generatesTerrain = true) where T : Thing
{
    T val = Prefab.Find<T>(thingData.PrefabName);
    if (val == null) { UnityEngine.Debug.LogWarning("Can't spawn " + thingData.PrefabName); return null; }
    if (!thingData.IsValidData()) { return null; }
    Vector3 worldPosition = thingData.WorldPosition;
    Quaternion worldRotation = thingData.WorldRotation;
    if (thingData is StructureSaveData structureSaveData)
    {
        worldPosition = structureSaveData.RegisteredWorldPosition;
        worldRotation = structureSaveData.RegisteredWorldRotation;
        ...                                       // only a rocket anchored to a moving fuselage overrides this
    }
    T val2 = Thing.Create<T>(val, worldPosition, worldRotation, thingData.ReferenceId);
    if (!val2) { return null; }
    val2.generateTerrain = generatesTerrain;
    val2.DeserializeSave(thingData);
    val2.ValidateOnLoad(CurrentSaveRevision);
    return val2;
}

So for a StructureSaveData, WorldPosition and WorldRotation are discarded and RegisteredWorldPosition / RegisteredWorldRotation are what the object is built from. Thing.DeserializeSave then re-applies the same two values to Position, ThingTransformPosition and ThingTransform.rotation, so they are read twice and agree. Structure.InitialiseSaveData writes them back out of RegisteredPosition / RegisteredRotation, so the round trip is closed.

Because thingData.ReferenceId is non-zero, Thing.Create takes the Referencable.RegisterAs branch rather than RegisterNew, and RegisterAs ends in the same thing.OnAssignedReference() call. From there the chain is the one in StructureRegistration, unchanged: Structure.OnAssignedReference -> GridController.Register -> AddGridStructure / AddSmallGridStructure -> Cell.Add -> Structure.OnRegistered. No method in that chain tests GameManager.GameState. The one load gate anywhere near it sits strictly after the grid work, in Structure.WorldChangeChecks(), and only suppresses the atmospheric "the world just changed here" event:

private void WorldChangeChecks()
{
    if (GameManager.GameState == GameState.None || AtmosphericsController == null) { return; }
    GameState gameState = GameManager.GameState;
    if (gameState == GameState.Loading || gameState == GameState.Joining) { return; }
    ...
}

The footprint itself, in GridController.AddGridStructure:

Span<Grid3> span = stackalloc Grid3[structure.GridBounds._grids.Length];
structure.GridBounds.GetLocalGrids(structure.ThingTransformPosition, structure.ThingTransformRotation, span);
...
cell = GetCell(grid);
if (cell == null) { cell = new Cell(grid, this); cell.Add(structure, grid); ... }
else if (!cell.Add(structure, grid)) { return; }
public void GetLocalGrids(Vector3 position, Quaternion rotation, Span<Grid3> localGrids)
{
    for (int i = 0; i < _grids.Length; i++)
    {
        Grid3 grid = _grids[i];
        Vector3 worldPosition = rotation * grid.ToVector3() + position;
        localGrids[i] = GridController.World.WorldToLocalGrid(worldPosition);
    }
}

_grids is the prefab's fixed local footprint. The non-PlacementSnap.Grid branch does the equivalent from BlockingGrids against structure.CenterPosition, which is why a face-mounted Wall registers into the cell its bounds centre falls in rather than the cell its Position names (see Wall).

Consequences, all of them things a save editor needs:

  • A transplanted <ThingSaveData> with correct coordinates registers exactly like a player-built piece. There is no cell list in ThingSaveData or StructureSaveData to author (the whole of StructureSaveData is CurrentBuildState, MothershipReferenceId, HasSpawnedWreckage, RegisteredWorldPosition, RegisteredWorldRotation, RocketRecord), and no checksum, Thing count or world hash covers the Things list. Checksum in this assembly is only the terrain chunk checksum, a trader-price one, and the quest IChecksum interface.
  • Stairs.Entry / Exit come out right on load, because they are set in Stairs.OnRegistered from the EntryPoint / ExitPoint child transforms and OnRegistered is called from AddGridStructure, which the load path reaches. A loaded flight is not mistaken for a stairwell. See Stairs.
  • A duplicate ReferenceId does not fail the load; it silently renumbers. RegisterAs refuses a colliding or zero id and pushes the Thing onto Referencable.FailedToRegister, and LoadWorld ends with Referencable.AssignNewIdToDuplicates(), which re-registers each one through RegisterNew (so it still lands in the grid) under a different id. An authored id must be unused anywhere in the file: Referencable.FindAndSetNextReferenceId(worldData) scans contacts, every network list, GetThings(), atmospheres, rockets, objectives and synced referencables for the maximum before the Things loop.
  • ThingSaveData.IsValidData() is the only per-entry gate and it is thin: negative ReferenceId, empty PrefabName, or float.IsNaN(WorldPosition.x). (It tests .x twice and never tests .y or .z; that is vanilla's bug, not a transcription error here.) An entry that fails it, or whose PrefabName Prefab.Find does not know, is skipped with a log line and the loop continues. Nothing aborts the load.
  • The one thing load does NOT do is reconcile atmospheres. Rooms are restored from their own persisted Grids lists before the Things loop, and WorldChangeChecks is suppressed, so a transplanted structure that ought to change a room boundary will not be noticed until something else forces a recompute. Transplant into open space or into a cell whose room membership does not change, or expect stale atmosphere data.

Structure.GetWorldGrid is not on this path, and it is not on the vanilla placement path either. Its only three callers in the assembly are ConstructionCursor.GetWorldGrid(...) inside PlacementMode(), the interactive build-preview loop. Its absence from a programmatic spawn or from the load path is therefore not evidence that anything was skipped: the snap those paths perform is the Grid3 round trip in OnAssignedReference and GetLocalGrids, a different mechanism. The "no snap" finding above is specifically about the 0.5 m small lattice a SmallGrid connectivity probe searches; large-grid cell membership is computed from position, rotation and GridBounds on every path and is never left unset.

Adding a member directly

The Add used by DeserializeSave is public on every network class, so a mod or dev-plugin can put a structure into an existing network without geometry:

  • CableNetwork.Add(Cable) [ded]:276723 : public void, ends cable.CableNetworkId = ReferenceId; after walking FillConnected<Device> to attach devices.
  • ReferencableNetwork<TMember>.Add(TMember) :193256 : public virtual bool, appends to the public readonly List<TMember> Members :193252 and calls OnMemberAdded. This is the base for PipeNetwork, ChuteNetwork, RoboticArmNetwork, LandingPadNetwork, RocketNetwork and StructureNetwork.

A direct Add on a listen host or dedicated server does not replicate. The client-side twin is driven by RebuildCableNetworkEvent (queued in CableNetwork.RebuildCableNetworkServer [ded]:263291, applied via CableNetwork.RebuildCableNetworkClient [ded]:263305 and ReferencableNetwork.RebuildNetworkClient :182931) or by the join serialisation above.

Verification history

  • 2026-09-23: added "A save-loaded structure registers by the same chain, and its cells are COMPUTED". Written while establishing whether a Spray Paint Plus playtest scene could be authored by transplanting <ThingSaveData> entries into world.xml instead of being built in-game. Sources, all read at 0.2.6428.27798 in .work/decomp/0.2.6428.27798/Assembly-CSharp.decompiled.cs: XmlSaveLoad.Load<T> / XmlSaveLoad.Load / XmlSaveLoad.LoadWorld (the class is XmlSaveLoad : ManagerBase, from line 273305, NOT WorldManager), StructureSaveData, Thing.DeserializeSave, Structure.DeserializeSave, Structure.InitialiseSaveData, Thing.Create<T>, Referencable.RegisterAs / RegisterNew / AssignNewIdToDuplicates / FindAndSetNextReferenceId, Structure.OnAssignedReference, GridController.Register / AddGridStructure, GridBounds.GetLocalGrids, Cell.Add, Structure.WorldChangeChecks, ThingSaveData.IsValidData, Stairs.OnRegistered, and a full-assembly caller search for GetWorldGrid and for Checksum. ADDITIVE, and deliberately so: the page's existing "programmatic path has no snap" section is about the 0.5 m small lattice and remains correct as written, so no fresh validator was required. The new section says explicitly that the same finding must not be generalised to large-grid cell membership, because it had been, and the generalisation was the reason a scene was believed unauthorable. Cross-checked against GameSystems/StructureRegistration.md (which already names save load as one of the paths sharing the chain and is confirmed, not contradicted), GameClasses/Stairs.md and GameClasses/Wall.md (both of which this section leans on and neither of which it changes).
  • 2026-09-21: page created. Written while investigating why structures placed through a dev-plugin's Constructor.SpawnConstruct call never joined each other's CableNetwork on the test rig (measured over eight layouts: both axes, yaw 0 and 90, spacing 0.5 m, 1 m and 2 m; recorded in Mods/SprayPaintPlus/playtests/FirstUseNoticeCap.cs near line 195). Sources: direct reads of Assembly-CSharp.decompiled.cs and Assembly-CSharp.DedicatedServer.decompiled.cs at 0.2.6428.27798 : Grid3 (:297308-297490), Vector3 grid extensions (:236069-236135), GridController (:211876-212372), GridBounds.GetLocalGrids / GetLocalSmallGrid (:319463-319487), Structure (:319656-321940, notably OnAssignedReference :320109, GetWorldGrid :320781, SetStructureData :321934), SmallGrid (:317942-318790), Cable (:399054-399200), CableNetwork ([ded]:262632-263320), CreateStructureInstance (:286919), Constructor ([ded]:287537-287577), OnServer.UseItemPrimary (:40456), the cursor update (:294214-294583), Pipe / Chute / RoboticArmRail* deserialize paths, and ReferencableNetwork<TMember> (:193250-193300). Additive: no existing page claimed anything about GetWorldGrid or about load-path network membership, so no fresh validator was required. Cross-checked against GameSystems/PlacementOrientation.md (which covers the rotation half of the same cursor pipeline and states "snaps cursor world position to grid" without naming the method) and against GameClasses/Grid3.md, GridController.md, CreateStructureInstance.md, Constructor.md, Cable.md, CableNetwork.md : no conflict found.

Open questions

  • Whether snapping through GetWorldGrid is sufficient for two programmatically placed straight cables to join, or whether the rotation and prefab variant must also be right. The open-end child transform offsets and GridBounds._gridsSmall are Unity serialized asset data and are not in the assembly; resolve with an InspectorPlus request (types=[Cable], fields=[OpenEnds, GridBounds, GridSize, GridOffset, RegisteredPosition, SmallCell]) on two placed straight cables that are known to share a network, or by a rig run after the snap is added.
  • Whether Ladder (: SmallGrid :311725), Stairs (: Structure :332839) and LargeStructure (:329794) placements are affected to the same degree. The large-grid kinds snap through GridBounds.GetLocalGrids :319468 with the default 2 m size, so their phase tolerance is 1 m rather than 0.25 m; no measurement exists either way.
  • Whether a hand-authored world.xml with synthesised network ids passes the loader's validation when the top-level <CableNetworks> / <PipeNetworks> / <ChuteNetworks> / <RoboticArmNetworks> lists and the per-Thing ids are kept in sync. Partially answered: the Spray Paint Plus fixture world has had pipe and chute ids rewritten by hand and a live load read the edited membership back exactly (63, 1 and 7 members on three networks, recorded in Mods/SprayPaintPlus/playtests/fixtures/SprayPaintPlusWorld.manifest.json). What is still untested is an id that appears on a Thing but in no top-level list, and the reverse.
  • What StructureCollisionType each prefab reports. It is a plain mutable field on Structure defaulting to CollisionType.BlockCustom, assigned nowhere in C# (searched the whole assembly), so the per-prefab values are Unity Inspector data baked into the .prefab assets and are not readable from a decompile. It does not decide whether a structure lands in Cell.AllStructures (every branch of Cell.Add adds it) but it does decide whether the structure also takes a face Lookup slot or the dedicated Cell.Stairs slot. Resolve with a live GET /thing fields=[StructureCollisionType] on a placed Wall and Frame.