MultiMergeConstructor¶
MultiMergeConstructor : MultiConstructor at Assets.Scripts.Objects.MultiMergeConstructor. The held-item kit form used by ItemKitPipe ("Kit (Pipe)"), ItemKitChute ("Kit (Basic Chutes)"), and the Super-Heavy cable kit. It is a regular MultiConstructor whose Constructables list includes both the 1-cell straight piece and the multi-cell "long" straight variants (3, 5, 10 segments), plus an extra ToolExit item that, when held in the off hand, lets the kit merge over an existing straight run instead of refusing placement.
This page exists because someone asked "what are the long pieces in Stationeers and where do they come from in the code." Short answer: there is no per-length C# class; the long variants are extra prefabs of the ordinary Pipe / Piping / Cable (and, for chutes, Chute) classes whose mesh/collider footprint spans N small-grid cells, registered into a MultiMergeConstructor kit's Constructables list.
The "long" straight-piece prefab family¶
From StreamingAssets\Language\english.xml (<RecordThing> keys = prefab names; values = display names). Each family has a 1-cell base piece and 3 / 5 / 10-segment long variants. "Burnt" variants exist for the cable family (electrocution damage state).
Gas pipes:
- StructurePipeStraight = "Pipe (Straight)" (1 cell) — note: this one can be merged and upgraded to insulated.
- StructurePipeStraight3 = "Pipe (Straight 3)", StructurePipeStraight5 = "Pipe (Straight 5)", StructurePipeStraight10 = "Pipe (Straight 10)". Description: "Long variant of the straight pipe. These variants cannot be merged or upgraded with insulation."
Insulated gas pipes:
- StructureInsulatedPipeStraight = "Insulated Pipe (Straight)" (1 cell).
- StructureInsulatedPipeStraight3 / ...5 / ...10 = "Insulated Pipe (Straight 3 / 5 / 10)".
Liquid pipes:
- StructurePipeLiquidStraight = "Liquid Pipe (Straight)" (1 cell).
- StructurePipeLiquidStraight3 / ...5 / ...10 = "Liquid Pipe (Straight 3 / 5 / 10)".
Insulated liquid pipes:
- StructureInsulatedPipeLiquidStraight = "Insulated Liquid Pipe (Straight)" (1 cell).
- StructureInsulatedPipeLiquidStraight3 / ...5 / ...10 = "Insulated Liquid Pipe (Straight 3 / 5 / 10)".
Chutes:
- StructureChuteStraight = "Chute (Straight)" (1 cell).
- StructureChuteStraight3 / ...5 / ...10 = "Chute (Straight 3 / 5 / 10)".
Cables — only the Super-Heavy cable tier has long variants (confirmed by the NetworkUpgrader Workshop mod's own description: "Super Heavy Cables ... the only cable type with long variants available"):
- StructureCableSuperHeavyStraight = "Super-Heavy Cable (Straight)" (1 cell), StructureCableSuperHeavyStraightBurnt.
- StructureCableSuperHeavyStraight3 / ...5 / ...10 = "Super-Heavy Cable (Straight 3 / 5 / 10)", each with a ...Burnt sibling.
- (Ordinary StructureCableStraight and Heavy StructureCableStraightH have no long variants.)
That is the complete set of "merge into a longer straight run" prefabs (6 families x {1, 3, 5, 10}, plus ...Burnt only for the cable family). StreamingAssets\Language\english.xml has no other <Key>...Straight[0-9]+</Key> keys; low-volume pipes and ducts have only ...CrossJunction3/4/5/6 (junction outlet counts, not multi-cell runs); no long walls / girders / beams / rails exist. Changelog corroboration: the WIP long straight insulated-liquid-pipe variants (3x / 5x / 10x) were an earlier beta experiment; Update v0.2.6085.26682 then changed the long super-heavy-cable and long pipe variants to the "straight asymmetric" smart-rotation type, and the new Super-Heavy cable (500 kW) shipped with 3 / 5 / 10 long variants for trunk lines.
Adjacent but a separate system, not a MultiMergeConstructor: the conveyor-belt prefabs StructureConveyorStraight / StructureConveyorStraightLong / StructureConveyorStraightShort / StructureConveyorCorner / StructureConveyorRiser and their kit ItemKitConveyor ("Kit (Conveyors)"). These are cut content — leftovers from the pre-chute "Flexi-belt" cargo system. The prefabs and string / Stationpedia entries still ship registered (so old saves and the prefab tables don't break), but no conveyor-specific C# class exists in Assembly-CSharp: Prefab.Find<Item>("ItemKitConveyor") is cast to plain Item, and an exhaustive grep -i conveyor of the assembly returns only LoreFactions.Recurso (the lore faction credited in the flavor text), a "Conveyors" audio-concurrency bucket, CrateType.ConveyorSupplies (whose spawn case adds 2x ItemKitConveyor), the vestigial DynamicThing.OnConveyor bool (read in the physics tick, never assigned true anywhere), and DeviceImportExport.ImportConveyorPosition / DeviceImportExport2.ExportConveyorPosition2 (Transform fields sitting next to Chute ImportChute / Chute ExportChute2 — chute drop-points, not belt code). The conveyor prefabs are spawnable via the creative-mode prefab spawner, but "building" one (right-mouse) errors because the construction wiring is gone. So StructureConveyorStraightLong is not a working MultiConstructor/MultiMergeConstructor kit option and is not merge-able — for a "remove the long pieces" mod it is a "hide a vestige" target (HideInStationpedia, drop from the creative spawn list), not the Constructables-strip + merge-reject + on-load-replace target the functional long pipe / chute / cable variants are. Full inventory: Research/GameSystems/ConveyorBeltCutContent.md. Also unrelated: StructureCompositeCladdingAngled*Long* and StructureLightLong* are cladding panels / light fixtures with a wider collider, not multi-cell pipe-style runs.
There is no StructurePipeStraight3 C# class, no ChuteStraight class, etc. Chute : SmallSingleGrid, ISmartRotatable, IChute, ... and Cable : SmallSingleGrid, IGridMergeable, ... and Pipe : SmallSingleGrid, ... / Piping : Pipe, IGridMergeable, ISmartRotatable are the only relevant classes; the long variants are instances of these.
How a long piece declares its length¶
A Structure's occupied small-grid cells come from GridBounds.GetLocalSmallGrid(position, rotation) -> Grid3[], ultimately computed in Structure.GetLocalSmallGridBounds() from the mesh/collider bounds scaled by BoundsGridRatio and the BoundsGridAdd* offsets (see Structure.md). A StructurePipeStraight3 prefab simply has a collider/mesh ~3 small cells long, so GetLocalSmallGrid returns 3 Grid3 entries; a ...10 returns 10. (Structure.ForceGridBounds : List<Grid3> overrides only GetLocalGridBounds, the large-grid set, not the small-grid set; the per-length sizing of these pieces is the mesh bounds, baked into the prefab asset, not into Assembly-CSharp.dll — the decompiled C# carries no length constant.)
Piping.CanReplace and Piping.WillMergeWhenPlaced both iterate (Grid3[])GridBounds.GetLocalSmallGrid(ThingTransformPosition, ThingTransformRotation) and look up each cell's SmallCell.Pipe, which is how a long ghost detects the run of existing 1-cell pipes it would absorb.
Class layout¶
public class MultiMergeConstructor : MultiConstructor
{
public Item ToolExit; // off-hand tool that enables "merge over an existing run"
public override int ConstructingSoundHash => string.IsNullOrEmpty(UsingSound) ? 0 : Animator.StringToHash(UsingSound);
public override int FinishedConstructingSoundHash => string.IsNullOrEmpty(UseCompleteSound) ? 0 : Animator.StringToHash(UseCompleteSound);
public override void Construct(Grid3 localPosition, Quaternion targetRotation, int optionIndex, Item offhandItem, bool authoringMode, ulong steamId);
}
ToolExit (display string MergeRequiresTool = "Merging requires <color=green>{Tool}</color> in other hand") is the item the player holds in the inactive hand to authorise a merge.
Construct: the merge path¶
public override void Construct(Grid3 localPosition, Quaternion targetRotation, int optionIndex, Item offhandItem, bool authoringMode, ulong steamId)
{
// No merge tool in off hand (and not authoring) -> behave like a plain MultiConstructor: place Constructables[optionIndex] as-is.
if (!authoringMode && (offhandItem == null || (offhandItem.PrefabHash != ToolExit.PrefabHash
&& offhandItem.ReplacementOf != null && ToolExit.PrefabHash != offhandItem.ReplacementOf.PrefabHash)))
{
base.Construct(localPosition, targetRotation, optionIndex, offhandItem, authoringMode, steamId);
return;
}
IGridMergeable gridMergeable = Constructables[optionIndex] as IGridMergeable;
StructureFuselage structureFuselage = Constructables[optionIndex] as StructureFuselage;
if (gridMergeable == null && structureFuselage == null) { base.Construct(...); return; }
IGridMergeable gridMergeable2 = null; Structure thing = null;
if (gridMergeable is Piping)
{
Piping piping = base.GridController.GetPipe(localPosition) as Piping;
if (piping) piping.PipeNetwork?.Remove(piping);
gridMergeable2 = piping; thing = piping;
}
else if (gridMergeable is Cable)
{
Cable cable = base.GridController.GetCable(localPosition);
if (cable) cable.CableNetwork?.Remove(cable);
gridMergeable2 = cable; thing = cable;
}
else if (structureFuselage) // hull/fuselage panels share this kit form
{
StructureFuselage existing = base.GridController.GetStructure(localPosition) as StructureFuselage;
existing?.StructureNetwork?.Remove(existing);
if (existing) OnServer.Destroy(existing);
base.Construct(localPosition, targetRotation, optionIndex, null, authoringMode, steamId, (!existing) ? 1 : 0);
return;
}
if (gridMergeable2 == null) { base.Construct(...); return; }
// Combine the open-end permutations of the new ghost and the existing piece, resolve the resulting
// SmartRotate.ConnectionType (Elbow / Straight / Tee / Cross / ...), pick the Constructables entry whose
// GetConnectionType() matches, compute the rotation that lines its open-ends up, destroy the old piece,
// and place the chosen variant charging only the *extra* item cost (EntryQuantity delta).
int[] permNew = gridMergeable.GetOpenEndLocationPermutation(Quaternion.identity, targetRotation);
int[] permOld = gridMergeable2.GetOpenEndLocationPermutation(Quaternion.identity);
int[] combined = new int[6];
for (int i = 0; i < 6; i++) combined[i] = Math.Max(permNew[i], permOld[i]);
// ... resolve connectionType via SmartRotate.OrientationLookup ...
// num2 = first Constructables index whose GetConnectionType() == connectionType
// index = first Constructables index whose GetConnectionType() == gridMergeable2.GetConnectionType()
OnServer.Destroy(thing);
int entryQuantity = Constructables[index].BuildStates[0].Tool.EntryQuantity;
int extra = Constructables[num2].BuildStates[0].Tool.EntryQuantity - entryQuantity;
base.Construct(localPosition, quaternion2, num2, null, authoringMode, steamId, extra);
}
Takeaways:
- Without
ToolExitin the off hand,MultiMergeConstructoris just aMultiConstructor: you select the long variant from the kit (mouse-wheel option) and place it on empty cells like any other piece. So the long pieces are directly placeable from the kit if (and only if) they sit in that kit's serializedConstructableslist. - With
ToolExitin the off hand and a ghost overlapping an existing pipe/cable, the kit absorbs the existing piece(s) into the larger variant, charging only the material difference. This is the "upgrade a straight run in place" affordance. - Only
Piping,Cable, andStructureFuselagehave a merge branch here.Chutedoes not implementIGridMergeable, so the chute kit's long variants go through the plainbase.Constructpath (place-as-is); they are not merge-absorbed.
Build costs, connection types, and kit Constructables ordering¶
The merge-cost delta num4 (the last argument to base.Construct, the quantity consumed from the kit stack) is Constructables[num2].BuildStates[0].Tool.EntryQuantity - Constructables[index].BuildStates[0].Tool.EntryQuantity. Both BuildStates[0].Tool (ToolUse : ToolBasic, Assembly-CSharp line 297369/294809) and the kit Constructables list (List<Structure>, line 288246) are serialized Unity prefab fields, NOT loaded from any StreamingAssets XML (grep of StreamingAssets for EntryQuantity returns nothing). They were read from $(StationeersPath)\rocketstation_Data\resources.assets via UnityPy + a generated type tree (the same technique as the per-tier MaxVoltage extraction documented in Research/GameClasses/Cable.md; load all 146 Managed/*.dll into a TypeTreeGenerator, attach to the UnityPy.load(rocketstation_Data) env, obj.read_typetree() per MonoBehaviour). ToolBasic.EntryQuantity is a plain public int; the arithmetic at line 288456 uses the raw integer (the StringManager.Get(EntryQuantity) calls at lines 297416/297448 are display-only tooltip formatting and do not change the stored value).
SmartRotate.ConnectionType (the construction enum, Assembly-CSharp line 217830) values referenced below: Elbow=0, SideOutletElbow=1, Straight=2, Tee=3, SideOutletTee=4, Cross=5, SideOutletCross=6, SixWayCross=7, Exhaustive=8, StraightAsymmetric=3000. GetConnectionType() on Cable/Piping/Chute just returns the serialized ConnectionType field (lines 335820, 371578); the C# field default is Exhaustive, but every placeable prefab overrides it at editor bake time, so the baked value is what matters and must be read from the asset, not inferred from the decompile.
Per-prefab build cost (EntryQuantity) and connection type (StraightAsym = StraightAsymmetric; EQ = EntryQuantity in kit stack units). The 1-cell straight base of every family is Straight (2), cost 1, IsStraight=1 -- it is NOT the C# default Exhaustive/None. The long variants are StraightAsymmetric (3000) with the family merge-block flag set (Piping.DontAllowMergingWithWrench=1 / Cable.BlockMergeWithOtherCables=1):
| Prefab | ConnType | EQ | Notes |
|---|---|---|---|
StructurePipeStraight |
Straight | 1 | 1-cell base, IsStraight=1 |
StructurePipeStraight3 / 5 / 10 |
StraightAsym | 3 / 4 / 8 | long; DontAllowMergingWithWrench=1 |
StructurePipeCorner |
Elbow | 1 | |
StructurePipeTJunction |
Tee | 2 | |
StructurePipeCrossJunction |
Cross | 2 | |
StructurePipeCrossJunction3 / 4 / 5 / 6 |
SideOutletElbow / SideOutletTee / SideOutletCross / SixWayCross | 2 / 3 / 3 / 3 | outlet-count junctions |
StructureCableStraight (normal) |
Straight | 1 | 1-cell base |
StructureCableCorner |
Elbow | 1 | |
StructureCableCorner3 / 4 |
SideOutletElbow / SideOutletTee | 2 / 3 | |
StructureCableJunction |
Tee | 2 | |
StructureCableJunction4 / 5 / 6 |
Cross / SideOutletCross / SixWayCross | 2 / 3 / 3 | |
StructureCableSuperHeavyStraight |
Straight | 1 | 1-cell base |
StructureCableSuperHeavyStraight3 / 5 / 10 |
StraightAsym | 3 / 4 / 7 | long; BlockMergeWithOtherCables=1 (note 10 = 7, not 8) |
StructureCableSuperHeavyJunction / 4 / 5 / 6 |
Tee / Cross / SideOutletCross / SixWayCross | 2 / 2 / 3 / 3 | |
StructureCableSuperHeavyCorner / 3 / 4 |
Elbow / SideOutletElbow / SideOutletTee | 1 / 3 / 3 | |
StructureChuteStraight |
Straight | 1 | 1-cell base, IsStraight field absent (Chute) |
StructureChuteStraight3 / 5 / 10 |
StraightAsym | 3 / 4 / 8 | long |
Heavy cable (StructureCableStraightH etc.), insulated pipe, liquid pipe, and insulated-liquid-pipe families mirror the normal-cable / gas-pipe cost+type pattern exactly (1-cell straight = Straight/1; corner = Elbow/1; T = Tee/2; cross = Cross/2; the side-outlet junctions = ⅔/3/3; long = StraightAsym ¾/8). Heavy cable's SideOutletElbow (StructureCableCornerH3) costs 3 (whereas normal cable's StructureCableCorner3 costs 2) -- the one inter-family cost difference. Full per-prefab dump (743 structure prefabs) in .work/2026-05-31-merge-num4-negative/structures.csv.
Cables do NOT all cost 1. Normal/heavy/super-heavy cable junctions cost 2-3 (Tee=2, Cross=2, the side-outlet variants=⅔). Only the 1-cell straight and the simple corner (Elbow) cost 1. So the "all cable variants cost 1, therefore cable num4 is always >= 0" premise is false as stated; cable num4 >= 0 holds for a different reason (below).
Kit Constructables ordering (index 0 is always the 1-cell Straight base; junctions occupy the middle; the long StraightAsymmetric variants are appended LAST). Verified for all merge-capable kits by resolving the serialized Constructables PPtr list (cross-file m_FileID resolved via each file's externals):
ItemKitPipe/ItemKitInsulatedPipe/ItemKitPipeLiquid/ItemKitInsulatedLiquidPipe(11 entries):[0]Straight,[1]Corner(Elbow),[2]TJunction(Tee),[3]CrossJunction(Cross),[4]CrossJunction5(SideOutletCross),[5]CrossJunction6(SixWayCross),[6]CrossJunction3(SideOutletElbow),[7]CrossJunction4(SideOutletTee),[8]Straight3,[9]Straight5,[10]Straight10.ItemCableCoil/ItemCableCoilHeavy(8 entries, no long variants):[0]Straight,[1]Corner(Elbow),[2]Junction(Tee),[3]Junction4(Cross),[4]Junction5(SideOutletCross),[5]Junction6(SixWayCross),[6]Corner3(SideOutletElbow),[7]Corner4(SideOutletTee).ItemCableCoilSuperHeavy(11 entries): same[0..7]as above, then[8]Straight3,[9]Straight5,[10]Straight10.ItemKitChute(12 entries, plainMultiConstructormerge path -- Chute is notIGridMergeable):[0]Straight,[1]Corner,[2]Window,[3]Junction,[4]FlipFlopSplitter,[5]Valve,[6]Overflow,[7]Inlet,[8]Outlet,[9]Straight3,[10]Straight5,[11]Straight10. (Resolves the prior open question: the chute long variants ARE serializedConstructablesmembers, placed as-is.)
Full ordered dump in .work/2026-05-31-merge-num4-negative/kits_resolved.json. This confirms the ZoopMod assumption that Constructables[0] is the 1-cell base straight (see "Implications for a 'remove the long pieces' mod" above).
Can the merge cost delta num4 go negative? No (in vanilla)¶
num4 < 0 would crash Stackable's RemoveRange-style consumption (a negative quantity into List.RemoveRange). In vanilla, with the build costs and connection types above, num4 is always in [0..2] -- never negative. Three independent gates, in Construct's own code path and in the placement validators that gate the click that reaches Construct, each block the negative case. The merge branch only engages for Piping and Cable (lines 288354-288372); Chute never reaches this code (base.Construct place-as-is).
-
Merged type
M(drivingnum2) can only be one of 8 junction/straight types, neverStraightAsymmetricorExhaustive. The resolver loop (lines 288413-288422) searchesOrientationLookup[ct]forctin exactly{Elbow, SideOutletElbow, Straight, Tee, SideOutletTee, Cross, SideOutletCross, SixWayCross}, first hit wins (fallbackElbow).num2= firstConstructablesentry whoseGetConnectionType()== thatM. Socost(num2)is always a junction/straight cost in{1,2,3}-- a long-variant cost (¾/⅞) can never be selected as the merged result. -
M's open-end count is always >= the existing piece's open-end count.combined[i] = Math.Max(permNew[i], permOld[i])(lines 288393-288399), socombinedpointwise-contains the existing piece's open-end patternpermOld. Every key inOrientationLookup[M]is a rotation ofM's base face-pattern (_ConstructRotationList, lines 218448-218472), and rotation preserves the open-end count;combinedis one such key, sopopcount(M) = popcount(combined) >= popcount(permOld) = popcount(E), whereE = gridMergeable2.GetConnectionType()is the existing piece's type andindex= firstConstructablesentry with typeE. The merge can only ADD open ends (merge a straight into a tee, a tee into a cross), never remove them. Cost is monotonic in outlet count across the kit (Straight/Elbow=1 at 2 ends; Tee/SideOutletElbow=2 at 3 ends; Cross=2, SideOutletTee=3 at 4 ends; SideOutletCross=3 at 5; SixWayCross=3 at 6), socost(M) >= cost(E)for every reachable transition. -
A long
StraightAsymmetricvariant can never be the existing pieceE.Piping.CanReplace(line 363974) rejects the merge (CannotMergeIMergeable) when EITHER the new ghost hasDontAllowMergingWithWrenchOR the existing cell's pipe isPiping { DontAllowMergingWithWrench: not false }.Cable._IsCollision(line 371561) returns collision when EITHER the existing OR the new cable hasBlockMergeWithOtherCables(same-tier branch:if (!cable.BlockMergeWithOtherCables) return BlockMergeWithOtherCables; return true;). Every long variant carries its family flag, so a long piece neither merges as the ghost nor can be merged onto as the existing piece. Without this gate, an existing long piece would setindexto the FIRSTStraightAsymmetrickit entry (always the...Straight3, cost 3 -- not the 10-variant's own cost, becauseindexis the first match) andnum2to a 2-end junction, which would givenum4 = 2 - 3 = -1; the gate makes that scenario unreachable.
Authoritative enumeration: over all 7 merge-capable kits, every reachable (E, M) transition (using a 24-proper-cube-rotation reachability model that is a strict SUPERSET of the game's actual RotationsList orbits, so the bound holds a fortiori), num4 in [0..2]. The most-negative arithmetic value that an UNCONSTRAINED (E, M) pairing could produce is -2 (existing = SideOutletCross/cost-3 junction, merged = Straight/cost-1), but that pairing is doubly unreachable: it violates gate 2 (a 5-end cross cannot merge down to a 2-end straight) and would also need the open-ends to shrink. Simulation scripts and outputs: .work/2026-05-31-merge-num4-negative/{sim_final.py, orbit_check.py, sim3.py}.
Caveat (out of vanilla scope): a THIRD-PARTY mod that adds a kit whose junction entries are cheaper than the existing piece they can merge from, or that bypasses CanReplace / _IsCollision (e.g. a custom placement path), or that re-bakes a long variant's ConnectionType to one of the 8 searched types while leaving it in Constructables, could reintroduce a negative num4. The "No tool required for pipe/cable merging" mod (id 3571613419) only removes the ToolExit requirement (the off-hand check), not the DontAllowMergingWithWrench / BlockMergeWithOtherCables gates, so it does not reopen the long-variant case. The bypass caveat is NOT hypothetical -- ZoopMod is exactly such a caller; see the next subsection.
Confirmed bypass: ZoopMod via InventoryManager.UsePrimaryComplete¶
Gate 3 is the placement validator, and it does NOT live in Construct. MultiMergeConstructor.Construct has no CanReplace/_IsCollision check of its own; the gate is in InventoryManager's Update-time placement loop. It computes flag2 = ConstructionCursor.CanConstruct().CanConstruct and, when !IsAuthoringMode && ConstructionCursor is IGridMergeable gridMergeable, ANDs in gridMergeable.CanReplace(multiConstructor, inactiveHandItem).CanConstruct (lines 270691-270712). Only when flag2 && KeyManager.GetMouseDown("Primary") does it stash _usePrimaryPosition/_usePrimaryRotation and call UsePrimaryComplete() (lines 270733-270750). UsePrimaryComplete() itself (line 271013) performs NO validation -- it just does OnServer.UseMultiConstructor(Parent, ActiveHand.SlotId, InactiveHand.SlotId, _usePrimaryPosition, _usePrimaryRotation, ConstructionPanel.BuildIndex, IsAuthoringMode, ParentBrain.ClientId, SpawnPrefab) (line 271019), which calls the 6-arg MultiConstructor.Construct (OnServer.UseMultiConstructor, line 40078).
So any caller that invokes the private parameterless UsePrimaryComplete() directly skips the CanConstruct/CanReplace gate. ZoopMod does exactly this: ZoopMod.Zoop.Placement.ZoopBuildExecutor.BuildAll reflectively sets _usePrimaryPosition / _usePrimaryRotation (private fields) and ConstructionPanel.BuildIndex, then Invokes the private parameterless UsePrimaryComplete() once per planned cell (verified from github.com/Nivvdiy/ZoopModRecovered, Zoop/Placement/ZoopBuildExecutor.cs, commit 1f02493). It runs CanReplace only at preview time against the pre-batch world (ZoopPreviewValidator.cs) and does not re-scan for pieces it placed earlier in the same batch. Therefore a ZoopMod batch CAN drive a merge onto an existing long (StraightAsymmetric) variant -- the exact case gate 3 blocks for the interactive cursor -- which makes index resolve to the first StraightAsymmetric kit entry (cost 3) while num2 resolves to a cheaper junction/straight, giving a negative num4 (collinear merge to Straight: 1 - 3 = -2; perpendicular to a Tee/Cross: 2 - 3 = -1). The negative flows num4 -> base.Construct(7-arg) -> if (!authoringMode) OnUseItem(num4, null) -> List.RemoveRange(_, num4) and throws ArgumentOutOfRangeException ("count"). With no try/catch around its per-cell invoke, that aborts ZoopMod's whole BuildAll coroutine (the MoveNext() frame in the crash trace) and skips its CancelPlacement() cleanup.
Mods/NetworkPuristPlus/'s lever-1 strip (removing the long variants from each kit's Constructables) removes the merge target entirely, so when its master + family toggles are on it prevents this crash rather than causing it. The crash is reachable when the long variants remain in the kit: NetworkPuristPlus master/family toggle off, a long-variant family NetworkPuristPlus does not manage, or a load-order gap where the strip has not run. Symptom on this build (live-verified, not inferred): the OnUseItem RemoveRange at line 335443 is gated by StackedGeneCollections != null. The C# only news that list on Plant, but Unity deserializes a public serialized List<T> field as a non-null EMPTY list on every stackable, so a stock ItemKitPipe reports StackedGeneCollections != null on the dedicated server (game 0.2.6228.27061). The negative num4 therefore reaches RemoveRange(2, -2) and throws ArgumentOutOfRangeException ("count") -- the crash fires on a plain kit on the host, matching the reporter. (An earlier draft of this note claimed the kit's list was null on 0.2.6228.27061 and that the negative num4 would silently duplicate items rather than throw; the live runtime read on the same build corrected it. See Research/GameClasses/Stackable.md.) Either way the negative num4 is the root defect, so a fix should sanitize the quantity.
IGridMergeable¶
public interface IGridMergeable : ISmartRotatable
{
CanConstructInfo CanReplace(MultiConstructor constructor, Assets.Scripts.Objects.Item inactiveHandItem);
bool WillMergeWhenPlaced();
}
Implemented by Cable (Cable : SmallSingleGrid, IGridMergeable, ISmartRotatable, IRocketInternals, IRocketComponent) and Piping (Piping : Pipe, IGridMergeable, ISmartRotatable). Piping.Type enum: normal, Insulated, NormalLowVolume, InsulatedLowVolume, Duct. Piping.DontAllowMergingWithWrench (bool) blocks merge for specific piping prefabs.
Piping.CanReplace rejects with GameStrings.CannotMergeIMergeable when the piece is Indestructable or the constructor is not a MultiMergeConstructor; rejects with GameStrings.MergeRequiresTool when no off-hand item / wrong off-hand item; rejects with GameStrings.CannotMergeIMergeableOfDifferentType when PipeType / PipeContentType differ; otherwise CanConstructInfo.ValidPlacement. Cable.CanReplace (around Assembly-CSharp.dll :: Cable) is the parallel implementation for cables. ConstructionCursor checks rotatable is IGridMergeable gm && gm.WillMergeWhenPlaced() while previewing placement.
Implications for a "remove the long pieces" mod¶
A mod that wants to make the ⅗/10 variants unavailable has these levers (none of them deletes the prefab type, just its reachability):
- Strip kit constructables — at prefab-load, remove every
StructurewhosePrefabNamematches*Straight(3|5|10)(and the Super-Heavy cable ones) from eachMultiMergeConstructor.Constructableslist (then letMultiConstructor.OnPrefabLoadclampLastSelectedIndex). This hides them from the kit's mouse-wheel options. Mirror of the "insert variant" recipe inPatterns/PrefabCloning.md. - Disable the merge tool — null/replace
MultiMergeConstructor.ToolExit, or patchPiping.CanReplace/Cable.CanReplaceto always returnInvalidPlacement. Removes the in-place upgrade affordance but not direct placement. - Block at construction — Harmony-prefix
MultiMergeConstructor.Construct(and/orMultiConstructor.Construct/Constructor.SpawnConstruct/CreateStructureInstance) and reject when the chosenConstructables[optionIndex](orCreateStructureInstance.Prefab) is a long variant. Catches every placement path including dev tools, at the cost of an unfiltered build menu. Caveat: ZoopMod (id 3310094883) drives placement throughInventoryManager.UsePrimaryCompleteand has no per-piece error recovery, so a hard reject here produces half-built zoops. Don't use lever 3 as a reject if ZoopMod compatibility matters; use lever 1 instead. - Unregister the prefab — remove the long-variant prefab from
Prefab.AllPrefabs/Prefab._allPrefabsafterPrefab.LoadAll. Fragile: any cached reference (recipes, Stationpedia, an already-loaded save with one placed) still points at it; a save containing a long variant fails to instantiate it on load. Keep the prefab registered if you also do lever 5. - Strip from saves — on save load (after structures are placed), enumerate placed
Structures andOnServer.Destroy/Thing.Deletelong variants, or replace each with N x the 1-cell base piece (NetworkUpgrader in reverse: preserve cell positions, rotation, custom color, and gas/atmos contents). Plain delete splits the network at that cell and loses the segment's contents; replace keeps the network. Run server-side / single-player only.
Existing-save concern: a save can already contain these prefabs (a player placed them via the kit / ToolExit merge, the NetworkUpgrader Workshop mod's upgrade <pipes|cables|chutes|all> console command converted runs into them, or a ZoopMod drag packed them in). Hiding from the kit does not affect already-placed instances; only lever 5 removes those.
Menu surfaces a long variant can appear in, and which lever clears each:
- In-world kit mouse-wheel /
ConstructionPanel— lever 1 (stripConstructables).ConstructionPanel(Assembly-CSharpline ~220492) holds[ReadOnly] public MultiConstructor Parentandint BuildIndex;Assign/SelectUp/SelectDownwalkBuildIndexoverParent.Constructablesonly, andMultiConstructor.Construct(... optionIndex ...)doesnew CreateStructureInstance(Constructables[optionIndex], ...). Nothing here consultsPrefab.AllPrefabs, so removing the prefab from the registry does not clean the wheel; you must mutateConstructables.MultiConstructor.OnPrefabLoad(~288251) already removesnullentries and clampsLastSelectedIndex— postfix it andConstructables.RemoveAll(s => s != null && isLong(s)), then re-clampLastSelectedIndexifCountchanged. No long variant is a single-pieceConstructor.BuildStructure, so there is no second wheel source to clear. - Stationpedia (page list + category lists + search) — set
Thing.HideInStationpedia = true(Assembly-CSharpline ~297850) on each long-variant prefab.Stationpedia.Regenerate()(~231012) ->PopulateThingPages()(~231964) loopsforeach (Thing allPrefab in Prefab.AllPrefabs)andcontinues (no page created) whenallPrefab.HideInStationpedia || DataHandler.HiddenInPedia[name]; the same guard gates the kit/category lists (~232470-232697) and search (DoSearchiteratesStationpediaPages, which the hidden prefab never enters). Pages are not cached across runs andRegeneratere-runs onLocalization.OnLanguageChanged, so set the flag onPrefab.OnPrefabsLoaded(it survives unless an XML page-override re-sets it insideDataHandler.HandleThingPageOverrides->thing.HideInStationpedia = item.HideInSPDA;at ~47703; vanilla has no override for these, but a belt-and-suspenders postfix onHandleThingPageOverridesre-setting the flag is ~5 extra lines). This is not much work; no reason to defer it. SeeResearch/GameClasses/Stationpedia.md/StationpediaPage.md/Research/GameSystems/StationpediaPageRendering.md/StationpediaSearch.md. - Fabricator recipe lists — nothing to do. Recipe outputs are always
DynamicThing/Item(Autolathe/ElectronicsPrinter/HydraulicPipeBenderRecipeComparableareDictionary<DynamicThing, Recipe>;Microwave/ChemistryStationareDictionary<Recipe, Item>). A long variant is aStructure(Pipe/Cable/Chute), so it cannot be a recipe output. TheHydraulicPipeBenderproduces the kits (ItemKitPipeetc.), which are not removed. Confirmed: no fabricator recipe emits a long-variant prefab. - Creative / admin spawn UI — out of scope (it iterates
Prefab.AllPrefabs; would need lever 4 or a population patch). The long-variant prefabs stay registered so old saves load, so they remain visible here.
ZoopMod interaction (verified from github.com/Nivvdiy/ZoopModRecovered, Zoop/Placement/ZoopLongVariantRules.cs): ZoopMod detects long variants by string-matching Constructables[0].GetPrefabName() + <digits> inside the active kit's Constructables list (no hard-coded prefab names or hashes), caches the result per base-piece object, then sets ConstructionPanel.BuildIndex and calls vanilla InventoryManager.UsePrimaryComplete. So lever 1 (strip the long variants from Constructables) makes ZoopMod's FindLongVariants return empty and it cleanly falls back to placing only the 1-cell base piece, with no ZoopMod-side errors. Two constraints: (a) keep Constructables[0] as the 1-cell base straight (ZoopMod assumes index 0); (b) strip before the first zoop preview of the session (a prefab-load / kit-init patch satisfies this), because ZoopMod caches _longVariantsByBasePiece. ZoopMod has no "disable long pieces" config to flip; BepInEx GUID "ZoopMod". Related Workshop mods: NetworkUpgrader (id 3656955459, upgrade pipes|cables|chutes|all console command) and "No tool required for pipe/cable merging" (id 3571613419, Nikku, removes the ToolExit requirement) — both only ever produce the vanilla long prefabs, so lever 1 + lever 5 cover them; do not null MultiMergeConstructor.ToolExit (lever 2) if you care about the Nikku mod, and it is unnecessary anyway once the long variants are out of Constructables. QuietPipesMod (id 3402741739) adds ItemKitQuietInsulatedPipe / ItemKitQuietInsulatedLiquidPipe; no evidence it adds long quiet-pipe variants, but a footprint-based predicate (below) catches them if it does.
Detecting a long-straight variant, and the implementation recipe¶
There is no single boolean meaning "I am a long straight variant" across the families. Cable.BlockMergeWithOtherCables (Assembly-CSharp line ~371306; this is IsLongStraightVariant renamed) and Piping.DontAllowMergingWithWrench (~363936) are set on the cable / pipe long variants respectively but are semantically "don't merge", are family-specific, and Chute has no such flag. The robust, family-agnostic predicate is footprint cell count:
// On a registered prefab, GridBounds is populated by Structure.CachePrefabBounds() (~297131):
// GridBounds = new GridBounds(this); -> _gridsSmall = (Grid3[])structure.GetLocalSmallGridBounds();
static bool IsLongStraightVariant(Structure s)
{
if (s is not (Pipe or Cable or Chute)) return false; // the three small-grid-pipe families
var cells = (Grid3[])s.GridBounds.GetLocalSmallGrid(Vector3.zero, Quaternion.identity);
return cells != null && cells.Length > 1; // 1-cell base == 1; long variant == 3/5/10
}
(On the prefab, use GridBounds.GetLocalSmallGrid, not Structure.BlockingGrids — BlockingGrids is the post-placement registered set, a Grid3[1] placeholder on a fresh prefab. Note Structure.ForceGridBounds overrides only GetLocalGridBounds (the large-grid set), not GetLocalSmallGridBounds, which always computes from the mesh bounds — so the small-cell count check is unaffected by ForceGridBounds. Optionally corroborate with (s as Cable)?.BlockMergeWithOtherCables == true || (s as Piping)?.DontAllowMergingWithWrench == true.)
Recipe:
Prefab.OnPrefabsLoadedhandler (orPrefab.LoadAllpostfix): scanPrefab.AllPrefabs, buildHashSet<int> longHashesofPrefabHashfor everything matchingIsLongStraightVariant; setHideInStationpedia = trueon each. Leave them inPrefab._allPrefabs/AllPrefabs.- Postfix
MultiConstructor.OnPrefabLoad:Constructables.RemoveAll(s => s != null && longHashes.Contains(s.PrefabHash)); if (LastSelectedIndex >= Constructables.Count) LastSelectedIndex = Constructables.Count - 1;(coversMultiMergeConstructorsince it inheritsOnPrefabLoad). - Postfix
World.OnLoadingFinished(Assets.Scripts.Objects.World, static,Assembly-CSharpline ~305850 —World.OnLoadingFinished(XmlSaveLoad.WorldData), called from the tail ofXmlSaveLoad.LoadWorld; it in turn callsGameManager.StartGame()synchronously, which flipsGameStatetoRunningand runsUpdateThingsOnGameStart-> per-ThingThing.OnFinishedLoad. PatchWorld.OnLoadingFinished, notGameManager.StartGamedirectly — the latter isasync UniTask.) Guardif (!GameManager.RunSimulation) return;(host / single-player only —Constructor.SpawnConstructon a client just sends a message and does nothing locally, andOnServer.Destroyis server-only). Then iterate a snapshot ofGridController.AllStructuresPool(the pool is mutated by destroy/create) and for each long-variant instance either: (a) delete —OnServer.Destroy(s)(deregisters its grid cells, removes it from itsPipeNetwork/CableNetwork/ChuteNetwork, splitting the run into disconnected ends and queuing the gas redistribution as aNetworkAtmosphereEvent, queues aDestroyEventto clients) — simplest, but loses the segment's gas/contents and splits the network there; or (b) replace — compute the run'sGrid3[]cells vias.GridBounds.GetLocalSmallGrid(s.ThingTransformPosition, s.ThingTransformRotation), capture rotation/owner/paint,OnServer.Destroy(s), thenConstructor.SpawnConstruct(new CreateStructureInstance(basePrefab, cell, rotation, owner, colorIdx))for each cell. BecauseGameState == Runningat this point, each new 1-cell piece'sOnRegisteredruns the network-merge branch (Pipe/Cable/Chute.OnRegistereddoesStructureNetwork.Merge(ConnectedNetworks(this))+.Add(this)only whenGameState != Loading), so the replacements rejoin the surrounding network with no extra calls — gas and connectivity preserved (modulo a transient pressure swing while the long piece's volume is briefly out of the network; the gas-divide event is async and usually runs after the replacements are back).SpawnConstructcharges no item cost and replicates created structures to connected clients via the normalThing.Create->NewToSendpath. The vanilla precedent for the destroy-then-SpawnConstructswap isCable.Break()(new CreateStructureInstance(RupturedPrefab, this); OnServer.Destroy(this); Constructor.SpawnConstruct(instance);). This is also the same iterate-and-OnServer.Destroyshape asWorldManager.DeleteOutOfBoundsObjects(~251634). - Do not add a hard reject in
MultiMergeConstructor.Construct/Constructor.SpawnConstruct— step 2 already makes the wheel unable to select a long variant, and a mid-placement reject breaks ZoopMod.
GridController.AllStructuresPool (Assets.Scripts.GridController, static readonly DensePool<Structure>("AllStructuresPool", 65536), line ~191276) is the canonical "all placed structures" collection (every Structure / SmallGridObject is added in GridController.Register, removed in Deregister); iterate with ForEach, snapshot before destroying. OcclusionManager.AllThings is the broader all-Thing pool if you need it.
Replace-on-load specifics worth knowing: the long-variant prefabs must stay registered in Prefab.AllPrefabs regardless of which lever you pick — XmlSaveLoad.LoadThing (Assembly-CSharp ~251312) does Prefab.Find(thingData.PrefabName) and silently drops the structure ("Can't spawn ..." warning) if the prefab is gone, so unregistering would lose every placed long piece before you can rebuild it. During the load itself (GameState == Loading) the network linkage is pre-established differently — XmlSaveLoad.LoadInNetworks pre-creates the network objects and each pipe/cable/chute's DeserializeSave re-adds itself to its saved network by id (Pipe.DeserializeSave: (Referencable.Find<PipeNetwork>(id) ?? new PipeNetwork(id)).Add(this), and likewise Cable/Chute) — which is why the merge-in-OnRegistered path is skipped during load and fires only for the replacements created at Running. For burnt long cables, StructureCableSuperHeavyStraight10Burnt etc. are CableRuptured prefabs (Cable.RupturedPrefab : CableRuptured; CableRuptured : SmallGrid — electrically dead, no CableNetwork), base StructureCableSuperHeavyStraightBurnt; the name+footprint predicate classifies them without a type check and they need no network rejoin or colour carry. For chutes, an item physically in transit sits in Chute.TransportSlot => Slots[0], and Chute does not override Thing.DestroyChildrenOnDead (default true), so OnServer.Destroy(chute) destroys the contained item — to avoid that, grab chute.TransportSlot.Occupant and OnServer.MoveToWorld(...) (or re-insert into a replacement) before destroying.
Mods/NetworkPuristPlus/ in this repo implements the replace variant: LongVariantRegistry (the OnPrefabsLoaded scan, HideInStationpedia, the imperative MultiConstructor.Constructables strip) and ReplaceLongPiecesOnLoadPatch (the World.OnLoadingFinished postfix). See its RESEARCH.md for the per-file walkthrough and the accepted limitations.
Verification history¶
- 2026-05-11: page created.
MultiMergeConstructorclass body,IGridMergeable, andPiping.CanReplacelifted verbatim from.work/decomp/0.2.6228.27061/Assembly-CSharp.decompiled.cs(lines ~288310-288459, ~215053, ~363925-363993). Prefab-name family enumerated fromStreamingAssets\Language\english.xml. The NetworkUpgrader Workshop mod (id 3656955459) corroborates "Super-Heavy is the only cable tier with long variants" and that long pieces are ⅗/10 segments. No conflicts with existing pages. - 2026-05-11: completeness re-check.
english.xml<Key>...Straight[0-9]+</Key>sweep confirms exactly 6 families x {3,5,10} (+...Burntonly for the cable family) and nothing else (low-volume pipes/ducts have only...CrossJunction3/4/5/6outlet-count junctions; no long walls/girders/beams/rails). In-game changelog (StreamingAssets\version.iniUPDATENOTES) cross-check: WIP long insulated-liquid-pipe 3x/5x/10x added in an earlier beta, then "long straight pipe variants" / "long straight super-heavy cable variants" / "long straight chute variants" moved to the new 'straight asymmetric' smart-rotate type, super-heavy-cable build requirements set for the 3x/5x/10x variants, and aCable.IsLongStraightVariantflag renamed toBlockMergeWithOtherCables. Open question 1 resolved: the long variants ARE in the stock kitConstructableslists -- ZoopMod's long-piece feature (Zoop/Placement/ZoopLongVariantRules.cs) works on vanilla kits by string-matchingConstructables[0].GetPrefabName() + <digits>and finds them, which it could not if they were absent. Added ZoopMod-interaction notes and the menu-surface map to the "Implications" section. - 2026-05-11: removal-mechanics deep dive (decompile pass for a planned StationeersPlus "remove the long pieces" mod). Confirmed: (a)
MultiConstructor.Constructablesis serialized directStructurerefs, notPrefab.AllPrefabslookups, so unregistering a prefab does not clean the kit wheel;MultiConstructor.OnPrefabLoadalready null-strips + clampsLastSelectedIndex. (b)Thing.HideInStationpedia(~297850) makesStationpedia.PopulateThingPages(~231964, loopsPrefab.AllPrefabs) skip a prefab entirely (page list, category lists, search) — pages are not cross-run cached,Regeneratere-runs on language change, the flag is normally written only from XML page-overrides (DataHandler.HandleThingPageOverrides~47703). © No fabricator recipe outputs a long variant (recipe outputs areDynamicThing/Item; long variants areStructures) -- resolves the old open question. (d) Save-load lifecycle:XmlSaveLoad.LoadWorld(~251347) ->LoadThing(~251312,Prefab.Findresolves the prefab, so it must stay registered) -> networks linked ->World.OnLoadingFinished(~305850) ->GameManager.StartGame(~189593) ->UpdateThingsOnGameStart(Thing.OnFinishedLoadper Thing); the post-load sweep belongs in a postfix onWorld.OnLoadingFinished(static,Assets.Scripts.Objects.World;GameManager.StartGameisasync UniTask, awkward to postfix), guarded byGameManager.RunSimulation. (e)GridController.AllStructuresPool(~191276,Assets.Scripts.GridController,DensePool<Structure>) is the canonical placed-structure collection;OnServer.Destroy(structure)is the correct removal (deregisters the grid cell, splits the run, queues clientDestroyEvent); iterate a snapshot. (f) Robust long-variant predicate:s is Pipe or Cable or Chute&&s.GridBounds.GetLocalSmallGrid(Vector3.zero, Quaternion.identity).Length > 1(on a prefab; notBlockingGrids), optionally corroborated byCable.BlockMergeWithOtherCables/Piping.DontAllowMergingWithWrench. Added the "Detecting a long-straight variant, and the implementation recipe" subsection. - 2026-05-11: replace-on-load mechanics + correction. The "remove the long pieces" mod was built as
Mods/NetworkPuristPlus/using the replace variant (destroy each long piece,Constructor.SpawnConstructthe N x 1-cell base at its cells with the same rotation/owner/paint; the replacements rejoin the network becauseOnRegistered's merge branch fires atGameState == Running). Added the replace recipe to the "implementation recipe" section. Findings folded in:Pipe/Cable/Chute.OnRegisteredrunsStructureNetwork.Merge(ConnectedNetworks(this)) + .Add(this)only whenGameState != Loading(during load the network is pre-linked byDeserializeSavefrom the saved network id);Cable.RupturedPrefab : CableRuptured(CableRuptured : SmallGrid, no network) so the...Burntlong-cable variants areCableRupturedprefabs;Chute.TransportSlot => Slots[0]andChutekeepsThing.DestroyChildrenOnDead == true, soOnServer.Destroyon a long chute destroys an in-transit item unless it is moved out first;Cable.Break()is the vanilla destroy-then-SpawnConstructprecedent. Correction:Structure.ForceGridBoundsoverrides onlyGetLocalGridBounds(large-grid), notGetLocalSmallGridBounds— the small-cell-count check is unaffected by it (the prior wording of this page implied otherwise; fixed in two places).Constructor.mdandCreateStructureInstance.mdwere checked and are already accurate (theSpawnConstructbody there already includes the0Lreference-id arg; bothCreateStructureInstancectors are present). - 2026-05-11: conflict on "
StructureConveyorStraightLongstatus". Previous claim: it is a distinct multi-cell buildable variant the player selects from the (plainMultiConstructor)ItemKitConveyorkit, with no merge-absorb. New finding: theStructureConveyor*prefabs andItemKitConveyorare cut content from the pre-chute "Flexi-belt" era — prefabs and string entries still ship registered, but no conveyor-specific C# class exists inAssembly-CSharp(only leftovers:LoreFactions.Recurso, a "Conveyors" audio bucket,CrateType.ConveyorSupplies, the never-setDynamicThing.OnConveyorbool, the chute-relatedDeviceImportExport.ImportConveyorPositiontransforms); spawnable via the creative spawner, errors on build. Fresh validator verdict: B is correct — the "buildable variant you select from the kit" wording was unsupported (the page cited only theenglish.xmlname list for it; the DLL has no construction wiring referencing the conveyor prefabs andOnConveyoris dead code). Result: rewrote the conveyor paragraph in "The 'long' straight-piece prefab family"; createdResearch/GameSystems/ConveyorBeltCutContent.md(full inventory) and linked it fromrelated:. - 2026-05-11:
Pipe.OnDestroygas-transfer dance, and the standalone-network gas-loss it causes when rebuilding.Pipe.OnDestroy(Assembly-CSharpline ~363201): snapshotsConnectedPipes()(before removal),PipeNetwork?.Remove(this), then ifRunSimulationcopies the whole network gas (GasMixture gasMixture = GasMixtureHelper.Create(); gasMixture.Set(pipeNetwork.Atmosphere.GasMixture);), callsnetworkedPipe.PipeNetwork.RebuildNetworkServer(networkedPipe)for each connected pipe (and collects their networks intolist2), then queuesNetworkAtmosphereEvent.DivideNetworkAtmosphere(list2, gasMixture)(or..., pipeNetwork.Atmosphere)whenpipeNetwork.Atmosphere.IsAwaitingEvent). Consequence for a "destroy each long pipe +Constructor.SpawnConstructthe N×1-cell base" rebuild:OnServer.Destroyis deferred to end-of-frame, all of a network's long pipes get destroyed together, eachOnDestroyqueues its ownDivideNetworkAtmosphere, and for a network that consisted entirely of long variants the gas does not make it to the rebuilt single-tile network — field-confirmed: NetworkPuristPlus rebuilding a standalone gas-editedStructurePipeStraight 10/5/3run (4 mol O2 + 16 mol N2 + 123 kJ in a 190 L network) left the rebuilt 18×StructurePipeStraight(correctly networked, correct 190 L<Volume>) withOxygen=0 Nitrogen=0 Energy=0; the original network id is gone, the rebuilt pipes are in a brand-new network with an empty atmosphere. Mixed networks (long pipes alongside regular pipes/devices) keep the gas because there are surviving pipes for the divide. Fix used inMods/NetworkPuristPlus/v1.0: capture each affectedPipeNetwork.Atmosphere.GasMixture(viaGasMixtureHelper.Create().Set(...)) and itsReferenceIdbefore destroying anything from it; after a 3-frameWaitForEndOfFrame-equivalent settle (so the deferred destroys + queued atmospherics events have run), if a sample rebuilt pipe'sPipeNetwork.ReferenceId != original(the original network died)Atmosphere.Add(capturedGas)into the rebuilt network. Cables/chutes carry no gas, so this only applies toPipe-derived long variants. Relevant types/namespaces:PipeAssets.Scripts.Objects.Pipes,PipeNetwork : AtmosphericsNetworkAssets.Scripts.Networks,Atmosphere : IReferencable,...Assets.Scripts.Atmospherics(NOT the unrelated staticAssets.Scripts.Networking.Atmosphere),GasMixture(struct) /GasMixtureHelperAssets.Scripts.Atmospherics,Atmosphere.Add(GasMixture)line ~190674. - 2026-05-11: correction to the "Detecting a long-straight variant" subsection — the footprint test does NOT work on a prefab.
Structure.GridBoundsisnew GridBounds()(the empty parameterless ctor;_gridsSmallempty) untilStructure.CachePrefabBounds()->GridBounds = new GridBounds(this)runs, andCachePrefabBounds()is called fromStructure.SetPrefab(Thing prefab)(when a Thing is instantiated from a prefab), not fromPrefab.RegisterExisting/Prefab.LoadAll. So on a never-yet-instantiated prefab atPrefab.OnPrefabsLoadedtime,GridBounds.GetLocalSmallGrid(Vector3.zero, Quaternion.identity)returns a zero-lengthGrid3[], andStructure.BlockingGridsis aGrid3[1]placeholder — neither reveals the real N cells. Field-confirmed: NetworkPuristPlus v1.0's first build gated prefab-time classification onfootprint > 1 celland consequently classified ZERO long variants (every candidate skipped at the footprint check; log line[Warning:NetworkPuristPlus] ... no long-variant prefabs found). Corrected approach: at prefab-load time classify by NAME only — aStructurewhose prefab name matches^(Structure[A-Za-z]+Straight)(\d+)(Burnt)?$AND whose de-numbered name (group1 + group3) is a registeredStructure(optionally alsos is SmallGridand/or theCable.BlockMergeWithOtherCables/Piping.DontAllowMergingWithWrenchfamily flags); theenglish.xml<Key>...Straight[0-9]+</Key>sweep confirms nothing outside the six families matches, so name+base-exists is precise. The footprint IS reliable on a placed instance (it has been instantiated, soCachePrefabBoundshas run) — that is where the on-load rebuild readsGetLocalSmallGrid(ThingTransformPosition, ThingTransformRotation)to get the run's cells. The "Detecting a long-straight variant, and the implementation recipe" code sample / parenthetical above still shows the prefab-footprint version and should be updated to the name-based predicate. (Confirmed by runtime behavior + decompile —GridBounds = new GridBounds()atAssembly-CSharpline ~295232,CachePrefabBoundsat ~297131 called fromSetPrefabat ~295701; not a fresh-validator pass.) NetworkPuristPlus v1.0 (rebuilt with the name-based predicate + per-rebuilt-pieceLogger.LogInfo) is the reference implementation. -
2026-05-11 (later): supersedes the gas-handling described in the earlier "
Pipe.OnDestroygas-transfer dance" entry — the capture-then-Atmosphere.Addre-inject does not work. Field-tested in NetworkPuristPlus: capturing each affectedPipeNetwork.Atmosphere.GasMixture(viaGasMixtureHelper.Create().Set(...)) before destroying and re-injecting it (Atmosphere.Add(...)) into the rebuilt network 3 frames later — the re-inject ran (log linerestored gas to N rebuilt pipe network(s)) but the rebuilt network's atmosphere still ended atOxygen=0 Nitrogen=0 Energy=0in the resulting save. Could not distinguish between (a) the capturedGasMixturestruct not actually copying the gas, (b)Atmosphere.Addnot taking, or © a late queuedNetworkAtmosphereEvent.DivideNetworkAtmosphereoverwriting it — would need runtime instrumentation (log captured / before-Add / after-Add mole counts, and theAtmosphericsManagerevent drain). Also: the gas loss is not limited to networks made entirely of long variants — it happens even when a regular 1-cell pipe is at the end of the long run. So: a destroy-then-SpawnConstructrebuild of a pipe run loses the run's gas, full stop. NetworkPuristPlus v1.0 therefore ships without gas preservation (the rebuilt single-tile pipes start empty; layout / rotation / colour / network connectivity ARE preserved); the gas-handling code was removed. A working fix is an open problem (likely: hook theAtmosphericsManagerevent-drain so you re-inject after allDivideNetworkAtmosphereevents have processed, and set rather than add the rebuilt network's atmosphere; or place the single-tile replacements before destroying the long pipes so the network never dies). Cables and chutes carry no gas, so this only concernsPipe-derived long variants. -
2026-05-31: extracted the serialized build costs (
BuildStates[0].Tool.EntryQuantity), bakedSmartRotate.ConnectionType, and orderedConstructableslists from$(StationeersPath)\rocketstation_Data\resources.assetsvia UnityPy + generated type tree (743 structure prefabs, 207 kits). Added two sections: "Build costs, connection types, and kit Constructables ordering" and "Can the merge cost delta num4 go negative? No (in vanilla)". Key facts, all NEW/additive (no contradiction with prior page content): 1-cell straight base =Straight(2)/cost 1 (NOT the C# defaultExhaustive); long variants =StraightAsymmetric(3000)/cost ¾/8 (super-heavy cable 10-variant = 7, the one off-pattern cost); cable junctions cost 2-3 (so "all cables cost 1" is false); kit order is always[0]1-cell straight, junctions in the middle, long variants appended last. Negative-num4analysis: vanillanum4 in [0..2], never negative, blocked by three gates -- (1) the merged type can only be one of 8 junction/straight types socost(num2) in {1,2,3}, never a long-variant cost; (2)combined = Math.Max(permNew, permOld)forcespopcount(M) >= popcount(E)so the merge only adds open ends andcost(M) >= cost(E); (3)Piping.CanReplace(363974) /Cable._IsCollision(371561) reject any merge where the existing or new piece carriesDontAllowMergingWithWrench/BlockMergeWithOtherCables, so a longStraightAsymmetricpiece can never be the existing pieceE. Verified by faithful resolver simulation over a 24-cube-rotation superset of the game'sRotationsListorbits. Scripts/outputs in.work/2026-05-31-merge-num4-negative/. Resolved the chute-kit open question (see below). Not a fresh-validator pass (all additive). -
2026-05-31 (later): confirmed the gate-3 "bypass
CanReplace" caveat is NOT hypothetical -- it is the mechanism behind a reported ZoopMod + long-variant crash. TheCanConstruct+CanReplacemerge gate lives inInventoryManager'sUpdate-time placement loop (lines 270691-270733), gated byflag2 && KeyManager.GetMouseDown("Primary"), BEFORE the privateUsePrimaryComplete()(line 271013), which itself does no validation and callsOnServer.UseMultiConstructordirectly (line 271019 -> 6-argMultiConstructor.Construct,OnServer.UseMultiConstructorat line 40078). ZoopMod (ZoopBuildExecutor.BuildAll, repogithub.com/Nivvdiy/ZoopModRecoveredcommit1f02493) reflectively invokesUsePrimaryComplete()per cell, bypassing the gate, so it can merge onto a longStraightAsymmetricvariant and drivenum4negative (1 - 3 = -2). Added the "Confirmed bypass: ZoopMod via InventoryManager.UsePrimaryComplete" subsection (with the version note that on 0.2.6228.27061 theOnUseItemRemoveRangeisStackedGeneCollections-gated, so a kit hits silent duplication rather than the throw on this exact build). NetworkPuristPlus'sConstructablesstrip is protective (removes the merge target) when its toggles are on, not causal. Verified by direct decompile reads ofUsePrimaryComplete/ the placement loop /OnServer.UseMultiConstructorand the ZoopMod source. Not a fresh-validator pass (additive; consistent with the gate-3 caveat already on the page). See alsoResearch/GameClasses/Stackable.md. (The "silent duplication on 0.2.6228.27061" parenthetical in this entry was corrected by the 2026-05-31 (later still) live-repro entry below.) -
2026-05-31 (later still): live-verified on the dedicated server (game 0.2.6228.27061) via a
ScenarioRunnerprobe (merge-long-variant-num4) that reproduces theindex/num2resolution over the live kitConstructables, reading each entry's runtimeGetConnectionType()+BuildStates[0].Tool.EntryQuantity. Results: (1) baseStructurePipeStraight=Straight/EQ 1, longStructurePipeStraight3=StraightAsymmetric/EQ 3, so merging a single straight onto an existing long givesnum4 = 1 - 3 = -2(and -3 / -7 for the 5 / 10 variants); confirmed on 5 merge-capable kits (pipe, liquid pipe, insulated pipe, insulated-liquid pipe, super-heavy cable).ItemKitPipeordering verified live: idx 0 =Straightbase (EQ 1), idx 8/9/10 = theStraightAsymmetriclongs (EQ ¾/8) -- matching the staticresources.assetsextraction exactly. (2) Fresh-validator correction: a stock kit'sStackedGeneCollectionsis a non-null EMPTY list at runtime (Unity serializes a publicList<T>field as empty, never null), NOT null -- a stockItemKitPipereports!= null. So the negativenum4reachesRemoveRange(2, -2)and THROWS on this exact build; the "silent duplication on 0.2.6228.27061" note in the entry above was wrong (it inferrednullfrom the decompiled C#). Updated the "Confirmed bypass" symptom note accordingly. (3) With NetworkPuristPlus active,ItemKitPipedrops 11 -> 8 entries (theStraightAsymmetriclongs stripped),mergeCapableWithLong = 0-- the merge-onto-long precondition is unreachable, confirming NPP is protective, not causal. Probe source:DedicatedServer/dev-plugins/ScenarioRunnerscenariomerge-long-variant-num4. -
2026-05-31 (later still, 2): a follow-up
merge-long-variant-num4run on the dedicated server (game 0.2.6228.27061, with NetworkPuristPlus and ZoopMod loaded) read the long super-heavy cable variants' runtimeGetConnectionType()asStraight, not theStraightAsymmetricthe table above and theresources.assetsextraction record; the long pipe and chute variants still readStraightAsymmetric.Cable.GetConnectionType()(decompile 371580) just returns the serializedConnectionTypefield, andSmartRotate.AutomaticSetup->SetConnectionTypecan overwrite it at runtime; NetworkPuristPlus runsAutomaticSetupover every straightCable, the likely cause. TheEntryQuantity(¾/7) and the negativenum4are unchanged. Implication: runtimeGetConnectionType()is NOT a reliable "is this a long variant" signal for the cable family (least of all with NetworkPuristPlus loaded -- the exact setup behind the reported ZoopMod crash); the footprint cell-count predicate already recommended in "Detecting a long-straight variant" is the family- and runtime-agnostic test, and is what the ZoopMod upstream fix for the crash uses to pick which cells to skip. Additive (the serialized table value is unchanged); the vanilla-runtime value is an open question below. Not a fresh-validator pass.
Open questions¶
- The exact
ToolExititem for each kit (likely a wrench-class tool;MergeRequiresToolshows it as a coloured display name at runtime). Confirm by inspecting the kit prefabs in-game. How the chute kit exposesRESOLVED 2026-05-31: they are plainStructureChuteStraight3/5/10Constructablesentries (ItemKitChuteindices 9/10/11,StraightAsymmetrictype, cost ¾/8), placed as-is viabase.ConstructsinceChuteis notIGridMergeable. See "Build costs, connection types, and kit Constructables ordering".- Does the long super-heavy cable's RUNTIME
GetConnectionType()readStraightAsymmetric(matching the serialized/baked table value) on a VANILLA server with no mod loaded? The 2026-05-31 dedi run that readStraighthad NetworkPuristPlus loaded, which runsSmartRotate.AutomaticSetupover straight cables and can rewriteConnectionType. A no-mod serverGetConnectionType()reading would confirm whether the serialized value survives to runtime in vanilla, or whether the cable family bakesStraightand only pipes/chutes bakeStraightAsymmetric.