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 inThingSaveDataorStructureSaveDatato author (the whole ofStructureSaveDataisCurrentBuildState,MothershipReferenceId,HasSpawnedWreckage,RegisteredWorldPosition,RegisteredWorldRotation,RocketRecord), and no checksum, Thing count or world hash covers the Things list.Checksumin this assembly is only the terrain chunk checksum, a trader-price one, and the questIChecksuminterface. Stairs.Entry/Exitcome out right on load, because they are set inStairs.OnRegisteredfrom theEntryPoint/ExitPointchild transforms andOnRegisteredis called fromAddGridStructure, which the load path reaches. A loaded flight is not mistaken for a stairwell. See Stairs.- A duplicate
ReferenceIddoes not fail the load; it silently renumbers.RegisterAsrefuses a colliding or zero id and pushes the Thing ontoReferencable.FailedToRegister, andLoadWorldends withReferencable.AssignNewIdToDuplicates(), which re-registers each one throughRegisterNew(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: negativeReferenceId, emptyPrefabName, orfloat.IsNaN(WorldPosition.x). (It tests.xtwice and never tests.yor.z; that is vanilla's bug, not a transcription error here.) An entry that fails it, or whosePrefabNamePrefab.Finddoes 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
Gridslists before the Things loop, andWorldChangeChecksis 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, endscable.CableNetworkId = ReferenceId;after walkingFillConnected<Device>to attach devices.ReferencableNetwork<TMember>.Add(TMember):193256 :public virtual bool, appends to the publicreadonly List<TMember> Members:193252 and callsOnMemberAdded. This is the base forPipeNetwork,ChuteNetwork,RoboticArmNetwork,LandingPadNetwork,RocketNetworkandStructureNetwork.
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 intoworld.xmlinstead 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 isXmlSaveLoad : ManagerBase, from line 273305, NOTWorldManager),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 forGetWorldGridand forChecksum. 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 againstGameSystems/StructureRegistration.md(which already names save load as one of the paths sharing the chain and is confirmed, not contradicted),GameClasses/Stairs.mdandGameClasses/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.SpawnConstructcall never joined each other'sCableNetworkon the test rig (measured over eight layouts: both axes, yaw 0 and 90, spacing 0.5 m, 1 m and 2 m; recorded inMods/SprayPaintPlus/playtests/FirstUseNoticeCap.csnear line 195). Sources: direct reads ofAssembly-CSharp.decompiled.csandAssembly-CSharp.DedicatedServer.decompiled.csat 0.2.6428.27798 :Grid3(:297308-297490),Vector3grid extensions (:236069-236135),GridController(:211876-212372),GridBounds.GetLocalGrids/GetLocalSmallGrid(:319463-319487),Structure(:319656-321940, notablyOnAssignedReference: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, andReferencableNetwork<TMember>(:193250-193300). Additive: no existing page claimed anything aboutGetWorldGridor about load-path network membership, so no fresh validator was required. Cross-checked againstGameSystems/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 againstGameClasses/Grid3.md,GridController.md,CreateStructureInstance.md,Constructor.md,Cable.md,CableNetwork.md: no conflict found.
Open questions¶
- Whether snapping through
GetWorldGridis 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 andGridBounds._gridsSmallare 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) andLargeStructure(:329794) placements are affected to the same degree. The large-grid kinds snap throughGridBounds.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.xmlwith 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 inMods/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
StructureCollisionTypeeach prefab reports. It is a plain mutable field onStructuredefaulting toCollisionType.BlockCustom, assigned nowhere in C# (searched the whole assembly), so the per-prefab values are Unity Inspector data baked into the.prefabassets and are not readable from a decompile. It does not decide whether a structure lands inCell.AllStructures(every branch ofCell.Addadds it) but it does decide whether the structure also takes a faceLookupslot or the dedicatedCell.Stairsslot. Resolve with a liveGET /thingfields=[StructureCollisionType]on a placed Wall and Frame.