Repository: space-wizards/space-station-14
Pull request #36492: Local Material Silo
Author: login: EmoGarbage404
Labels: name: P3: Standard
description: Priority: Default priority for repository items.
name: T: New Feature
description: Type: New feature or content, or extending existing content
name: Changes: UI
description: Changes: Might require knowledge of UI design or code.
name: D2: Medium
description: Difficulty: A good amount of codebase knowledge required.
name: Changes: Sprites
description: Changes: Might require knowledge of spriting or visual design.
name: S: Needs Review
description: Status: Requires additional reviews before being fully accepted. Not to be replaced by S: Approved.
name: S: Approved
description: Status: Reviewed and approved by at least one maintainer; a PR may require another approval.
name: A: Cargo/Salvage
description: Area: Cargo department or Salvage.
name: A: General Interactions
description: Area: General in-game interactions that don't relate to another area.
name: size/M
description: Denotes a PR that changes 100-999 lines.
name: S: Art Approval
description: Status: The art (e.g. Spritework) has been approved by an art lead.
Created: 2025-04-12T17:18:23Z | Merged: 2025-04-18T23:43:17Z

<!-- Guidelines: https://docs.spacestation14.io/en/getting-started/pr-guideline -->

## About the PR
<!-- What did you change? -->
Adds the material silo as a way to reduce some annoying material management within a department.

The silo has a range of 25 (approx. one large department) and all lathes/material storage items linked to it will share a single pool of resources.

A new silo can be purchased via cargo for 5k (to stop spam and theft cheese)

## Why / Balance
<!-- Discuss how this would affect game balance or explain why it was changed. Link any relevant discussions or issues. -->
The goal is to reduce situations where material management is unwieldy due to being split between tons of machines in a small area (science stuff being split between autolathe, protolathe, circuit imprinter, exosuit fab, flatpacker, etc)

The goal is _not_ to streamline delivery, as indicated by the limit on the range. People will still need to collect materials and bring them back to the department.

Machines are linked through the silo UI and must be within range. The silo also has to be powered to transmit materials.

## Technical details
<!-- Summary of code changes for easier review. -->
Adds various events to SharedMaterialStorage system.

## Media
<!-- Attach media if the PR makes ingame changes (clothing, items, features, etc). 
Small fixes/refactors are exempt. Media may be used in SS14 progress reports with credit. -->

https://github.com/user-attachments/assets/0df7499b-a7e3-40cc-a5af-55ae58631626


## Requirements
<!-- Confirm the following by placing an X in the brackets [X]: -->
- [x] I have read and am following the [Pull Request and Changelog Guidelines](https://docs.spacestation14.com/en/general-development/codebase-info/pull-request-guidelines.html).
- [x] I have added media to this PR or it does not require an ingame showcase.
<!-- You should understand that not following the above may get your PR closed at maintainer’s discretion -->

## Breaking changes
<!-- List any breaking changes, including namespaces, public class/method/field changes, prototype renames; and provide instructions for fixing them.
This will be posted in #codebase-changes. -->

**Changelog**
:cl:
- add: Added the material silo. This machine allows nearby machines to share a pool of resources, cutting down on micromanagement.



Diff:
diff --git a/Content.Client/Lathe/UI/LatheMenu.xaml b/Content.Client/Lathe/UI/LatheMenu.xaml
index d5e35731487ac..28b79254c02f7 100644
--- a/Content.Client/Lathe/UI/LatheMenu.xaml
+++ b/Content.Client/Lathe/UI/LatheMenu.xaml
@@ -127,12 +127,17 @@
             HorizontalExpand="True"
             Orientation="Vertical">
             <Label Text="{Loc 'lathe-menu-materials-title'}" Margin="5 5 5 5" HorizontalAlignment="Center"/>
-            <BoxContainer
-                Orientation="Vertical"
-                VerticalExpand="True"
-                HorizontalExpand="True">
-                <ui:MaterialStorageControl Name="MaterialsList" SizeFlagsStretchRatio="8"/>
-            </BoxContainer>
+            <PanelContainer VerticalExpand="True">
+                <PanelContainer.PanelOverride>
+                    <gfx:StyleBoxFlat BackgroundColor="#1B1B1E" />
+                </PanelContainer.PanelOverride>
+                <BoxContainer
+                    Orientation="Vertical"
+                    VerticalExpand="True"
+                    HorizontalExpand="True">
+                    <ui:MaterialStorageControl Name="MaterialsList" SizeFlagsStretchRatio="8"/>
+                </BoxContainer>
+            </PanelContainer>
         </BoxContainer>
         </BoxContainer>
 
diff --git a/Content.Client/Materials/OreSiloSystem.cs b/Content.Client/Materials/OreSiloSystem.cs
new file mode 100644
index 0000000000000..076a546d37365
--- /dev/null
+++ b/Content.Client/Materials/OreSiloSystem.cs
@@ -0,0 +1,6 @@
+using Content.Shared.Materials.OreSilo;
+
+namespace Content.Client.Materials;
+
+/// <inheritdoc/>
+public sealed class OreSiloSystem : SharedOreSiloSystem;
diff --git a/Content.Client/Materials/UI/MaterialStorageControl.xaml b/Content.Client/Materials/UI/MaterialStorageControl.xaml
index 2be0f40aa51a4..d7503a61f3b99 100644
--- a/Content.Client/Materials/UI/MaterialStorageControl.xaml
+++ b/Content.Client/Materials/UI/MaterialStorageControl.xaml
@@ -2,7 +2,10 @@
               SizeFlagsStretchRatio="8"
               HorizontalExpand="True"
               VerticalExpand="True">
-    <BoxContainer Name="MaterialList" Orientation="Vertical">
-        <Label Name="NoMatsLabel" Text="{Loc 'lathe-menu-no-materials-message'}" Align="Center"/>
+    <BoxContainer Orientation="Vertical" VerticalExpand="True">
+        <BoxContainer Name="MaterialList" Orientation="Vertical" VerticalExpand="True">
+            <Label Name="NoMatsLabel" Text="{Loc 'lathe-menu-no-materials-message'}" HorizontalAlignment="Center" VerticalAlignment="Center" VerticalExpand="True"/>
+        </BoxContainer>
+        <Label Name="SiloLinkedLabel" Text="{Loc 'lathe-menu-silo-linked-message'}" StyleClasses="LabelSubText" Visible="False" HorizontalAlignment="Center"/>
     </BoxContainer>
 </ScrollContainer>
diff --git a/Content.Client/Materials/UI/MaterialStorageControl.xaml.cs b/Content.Client/Materials/UI/MaterialStorageControl.xaml.cs
index 3cf1792c144c5..fd698d890fc46 100644
--- a/Content.Client/Materials/UI/MaterialStorageControl.xaml.cs
+++ b/Content.Client/Materials/UI/MaterialStorageControl.xaml.cs
@@ -1,5 +1,6 @@
 using System.Linq;
 using Content.Shared.Materials;
+using Content.Shared.Materials.OreSilo;
 using Robust.Client.AutoGenerated;
 using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.XAML;
@@ -15,6 +16,7 @@ namespace Content.Client.Materials.UI;
 public sealed partial class MaterialStorageControl : ScrollContainer
 {
     [Dependency] private readonly IEntityManager _entityManager = default!;
+    private readonly MaterialStorageSystem _materialStorage;
 
     private EntityUid? _owner;
 
@@ -24,6 +26,8 @@ public MaterialStorageControl()
     {
         RobustXamlLoader.Load(this);
         IoCManager.InjectDependencies(this);
+
+        _materialStorage = _entityManager.System<MaterialStorageSystem>();
     }
 
     public void SetOwner(EntityUid owner)
@@ -45,7 +49,8 @@ protected override void FrameUpdate(FrameEventArgs args)
         }
 
         var canEject = materialStorage.CanEjectStoredMaterials;
-        var mats = materialStorage.Storage;
+        var mats = _materialStorage.GetStoredMaterials((_owner.Value, materialStorage));
+
         if (_currentMaterials.Equals(mats))
             return;
 
@@ -89,5 +94,6 @@ protected override void FrameUpdate(FrameEventArgs args)
 
         _currentMaterials = mats;
         NoMatsLabel.Visible = MaterialList.ChildCount == 1;
+        SiloLinkedLabel.Visible = _entityManager.TryGetComponent<OreSiloClientComponent>(_owner.Value, out var client) && client.Silo != null;
     }
 }
diff --git a/Content.Client/Materials/UI/OreSiloBoundUserInterface.cs b/Content.Client/Materials/UI/OreSiloBoundUserInterface.cs
new file mode 100644
index 0000000000000..32eed56d80adf
--- /dev/null
+++ b/Content.Client/Materials/UI/OreSiloBoundUserInterface.cs
@@ -0,0 +1,34 @@
+using Content.Shared.Materials.OreSilo;
+using JetBrains.Annotations;
+using Robust.Client.UserInterface;
+
+namespace Content.Client.Materials.UI;
+
+[UsedImplicitly]
+public sealed class OreSiloBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
+{
+    [ViewVariables]
+    private OreSiloMenu? _menu;
+
+    protected override void Open()
+    {
+        base.Open();
+
+        _menu = this.CreateWindow<OreSiloMenu>();
+        _menu.SetEntity(Owner);
+
+        _menu.OnClientEntryPressed += netEnt =>
+        {
+            SendPredictedMessage(new ToggleOreSiloClientMessage(netEnt));
+        };
+    }
+
+    protected override void UpdateState(BoundUserInterfaceState state)
+    {
+        base.UpdateState(state);
+
+        if (state is not OreSiloBuiState msg)
+            return;
+        _menu?.Update(msg);
+    }
+}
diff --git a/Content.Client/Materials/UI/OreSiloMenu.xaml b/Content.Client/Materials/UI/OreSiloMenu.xaml
new file mode 100644
index 0000000000000..3a8e0ef48a81e
--- /dev/null
+++ b/Content.Client/Materials/UI/OreSiloMenu.xaml
@@ -0,0 +1,42 @@
+﻿<controls:FancyWindow xmlns="https://spacestation14.io"
+                     xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+                     xmlns:graphics="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
+                     xmlns:ui="clr-namespace:Content.Client.Materials.UI"
+                     Title="{Loc 'ore-silo-ui-title'}"
+                     MinSize="400 260"
+                     SetSize="400 460">
+    <BoxContainer Orientation="Vertical"
+                  HorizontalExpand="True"
+                  VerticalExpand="True"
+                  Margin="10 10 10 10">
+        <BoxContainer VerticalExpand="True"
+                      HorizontalExpand="True"
+                      Orientation="Vertical"
+                      SizeFlagsStretchRatio="3">
+            <Label Text="{Loc 'ore-silo-ui-label-clients'}" Margin="5 5 5 5" HorizontalAlignment="Center" StyleClasses="LabelKeyText"/>
+            <PanelContainer VerticalExpand="True">
+                <PanelContainer.PanelOverride>
+                    <graphics:StyleBoxFlat BackgroundColor="#1B1B1E" />
+                </PanelContainer.PanelOverride>
+                <ItemList Name="ClientList" SelectMode="Button" VerticalExpand="True"/>
+            </PanelContainer>
+        </BoxContainer>
+        <BoxContainer VerticalExpand="True"
+                      HorizontalExpand="True"
+                      Orientation="Vertical"
+                      SizeFlagsStretchRatio="2">
+            <Label Text="{Loc 'ore-silo-ui-label-mats'}" Margin="5 5 5 5" HorizontalAlignment="Center" StyleClasses="LabelKeyText"/>
+            <PanelContainer VerticalExpand="True">
+                <PanelContainer.PanelOverride>
+                    <graphics:StyleBoxFlat BackgroundColor="#1B1B1E" />
+                </PanelContainer.PanelOverride>
+                <BoxContainer
+                    Orientation="Vertical"
+                    VerticalExpand="True"
+                    HorizontalExpand="True">
+                    <ui:MaterialStorageControl Name="Materials"/>
+                </BoxContainer>
+            </PanelContainer>
+        </BoxContainer>
+    </BoxContainer>
+</controls:FancyWindow>
diff --git a/Content.Client/Materials/UI/OreSiloMenu.xaml.cs b/Content.Client/Materials/UI/OreSiloMenu.xaml.cs
new file mode 100644
index 0000000000000..69d1fa328e3d0
--- /dev/null
+++ b/Content.Client/Materials/UI/OreSiloMenu.xaml.cs
@@ -0,0 +1,64 @@
+using System.Linq;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Materials.OreSilo;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Materials.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class OreSiloMenu : FancyWindow
+{
+    public event Action<NetEntity>? OnClientEntryPressed;
+
+    public OreSiloMenu()
+    {
+        RobustXamlLoader.Load(this);
+
+        ClientList.OnItemSelected += args =>
+        {
+            var item = ClientList[args.ItemIndex];
+            // a little bit of null suppression makes me feel great! :-)
+            OnClientEntryPressed?.Invoke((NetEntity) item.Metadata!);
+        };
+    }
+
+    public void SetEntity(EntityUid uid)
+    {
+        Materials.SetOwner(uid);
+    }
+
+    public void Update(OreSiloBuiState state)
+    {
+        var items = new List<ItemList.Item>();
+        var orderedClients = state.Clients.OrderBy(t => t.Item3).ThenBy(t => t.Item1.Id);
+        foreach (var (ent, _, _) in orderedClients)
+        {
+            items.Add(new ItemList.Item(ClientList)
+            {
+                Metadata = ent
+            });
+        }
+
+        ClientList.SetItems(items,
+            (item1, item2) =>
+            {
+                var ent1 = (NetEntity) item1.Metadata!;
+                var ent2 = (NetEntity) item2.Metadata!;
+                return ent1.CompareTo(ent2);
+            });
+
+        var entTextDict = state.Clients.Select(t => (t.Item1, t.Item2)).ToDictionary();
+        using var enumerator = ClientList.GetEnumerator();
+        while (enumerator.MoveNext())
+        {
+            if (enumerator.Current.Metadata is not NetEntity ent)
+                continue;
+
+            if (entTextDict.TryGetValue(ent, out var text))
+                enumerator.Current.Text = text;
+        }
+    }
+}
+
diff --git a/Content.Server/Materials/MaterialStorageSystem.cs b/Content.Server/Materials/MaterialStorageSystem.cs
index 8c50d5fe9af52..3a462dd4d5e64 100644
--- a/Content.Server/Materials/MaterialStorageSystem.cs
+++ b/Content.Server/Materials/MaterialStorageSystem.cs
@@ -102,14 +102,18 @@ public override bool TryInsertMaterialEntity(EntityUid user,
         if (!base.TryInsertMaterialEntity(user, toInsert, receiver, storage, material, composition))
             return false;
         _audio.PlayPvs(storage.InsertingSound, receiver);
-        _popup.PopupEntity(Loc.GetString("machine-insert-item", ("user", user), ("machine", receiver),
-            ("item", toInsert)), receiver);
+        _popup.PopupEntity(Loc.GetString("machine-insert-item",
+                ("user", user),
+                ("machine", receiver),
+                ("item", toInsert)),
+            receiver);
         QueueDel(toInsert);
 
         // Logging
         TryComp<StackComponent>(toInsert, out var stack);
         var count = stack?.Count ?? 1;
-        _adminLogger.Add(LogType.Action, LogImpact.Low,
+        _adminLogger.Add(LogType.Action,
+            LogImpact.Low,
             $"{ToPrettyString(user):player} inserted {count} {ToPrettyString(toInsert):inserted} into {ToPrettyString(receiver):receiver}");
         return true;
     }
diff --git a/Content.Server/Materials/OreSiloSystem.cs b/Content.Server/Materials/OreSiloSystem.cs
new file mode 100644
index 0000000000000..87e6db16cf3d8
--- /dev/null
+++ b/Content.Server/Materials/OreSiloSystem.cs
@@ -0,0 +1,119 @@
+using Content.Server.Pinpointer;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Materials.OreSilo;
+using Robust.Server.GameStates;
+using Robust.Shared.Player;
+
+namespace Content.Server.Materials;
+
+/// <inheritdoc/>
+public sealed class OreSiloSystem : SharedOreSiloSystem
+{
+    [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
+    [Dependency] private readonly NavMapSystem _navMap = default!;
+    [Dependency] private readonly PvsOverrideSystem _pvsOverride = default!;
+    [Dependency] private readonly SharedUserInterfaceSystem _userInterface = default!;
+
+    private const float OreSiloPreloadRangeSquared = 225f; // ~1 screen
+
+    private readonly HashSet<Entity<OreSiloClientComponent>> _clientLookup = new();
+    private readonly HashSet<(NetEntity, string, string)> _clientInformation = new();
+    private readonly HashSet<EntityUid> _silosToAdd = new();
+    private readonly HashSet<EntityUid> _silosToRemove = new();
+
+    protected override void UpdateOreSiloUi(Entity<OreSiloComponent> ent)
+    {
+        if (!_userInterface.IsUiOpen(ent.Owner, OreSiloUiKey.Key))
+            return;
+        _clientLookup.Clear();
+        _clientInformation.Clear();
+
+        var xform = Transform(ent);
+
+        // Sneakily uses override with TComponent parameter
+        _entityLookup.GetEntitiesInRange(xform.Coordinates, ent.Comp.Range, _clientLookup);
+
+        foreach (var client in _clientLookup)
+        {
+            // don't show already-linked clients.
+            if (client.Comp.Silo is not null)
+                continue;
+
+            var netEnt = GetNetEntity(client);
+            var name = Identity.Name(client, EntityManager);
+            var beacon = _navMap.GetNearestBeaconString(client.Owner, onlyName: true);
+
+            var txt = Loc.GetString("ore-silo-ui-itemlist-entry",
+                ("name", name),
+                ("beacon", beacon),
+                ("linked", ent.Comp.Clients.Contains(client)),
+                ("inRange", true));
+
+            _clientInformation.Add((netEnt, txt, beacon));
+        }
+
+        // Get all clients of this silo, including those out of range.
+        foreach (var client in ent.Comp.Clients)
+        {
+            var netEnt = GetNetEntity(client);
+            var name = Identity.Name(client, EntityManager);
+            var beacon = _navMap.GetNearestBeaconString(client, onlyName: true);
+            var inRange = CanTransmitMaterials((ent, ent), client);
+
+            var txt = Loc.GetString("ore-silo-ui-itemlist-entry",
+                ("name", name),
+                ("beacon", beacon),
+                ("linked", ent.Comp.Clients.Contains(client)),
+                ("inRange", inRange));
+
+            _clientInformation.Add((netEnt, txt, beacon));
+        }
+
+        _userInterface.SetUiState(ent.Owner, OreSiloUiKey.Key, new OreSiloBuiState(_clientInformation));
+    }
+
+    public override void Update(float frameTime)
+    {
+        base.Update(frameTime);
+
+        // Solving an annoying problem: we need to send the silo to people who are near the silo so that
+        // Things don't start wildly mispredicting. We do this as cheaply as possible via grid-based local-pos checks.
+        // Sloth okay-ed this in the interim until a better solution comes around.
+
+        var actorQuery = EntityQueryEnumerator<ActorComponent, TransformComponent>();
+        while (actorQuery.MoveNext(out _, out var actorComp, out var actorXform))
+        {
+            _silosToAdd.Clear();
+            _silosToRemove.Clear();
+
+            var clientQuery = EntityQueryEnumerator<OreSiloClientComponent, TransformComponent>();
+            while (clientQuery.MoveNext(out _, out var clientComp, out var clientXform))
+            {
+                if (clientComp.Silo == null)
+                    continue;
+
+                // We limit it to same-grid checks only for peak perf
+                if (actorXform.GridUid != clientXform.GridUid)
+                    continue;
+
+                if ((actorXform.LocalPosition - clientXform.LocalPosition).LengthSquared() <= OreSiloPreloadRangeSquared)
+                {
+                    _silosToAdd.Add(clientComp.Silo.Value);
+                }
+                else
+                {
+                    _silosToRemove.Add(clientComp.Silo.Value);
+                }
+            }
+
+            foreach (var toRemove in _silosToRemove)
+            {
+                _pvsOverride.RemoveSessionOverride(toRemove, actorComp.PlayerSession);
+            }
+            foreach (var toAdd in _silosToAdd)
+            {
+                _pvsOverride.AddSessionOverride(toAdd, actorComp.PlayerSession);
+            }
+        }
+    }
+}
diff --git a/Content.Server/Pinpointer/NavMapSystem.cs b/Content.Server/Pinpointer/NavMapSystem.cs
index da9ab20a39abc..3ab0e8eff1a42 100644
--- a/Content.Server/Pinpointer/NavMapSystem.cs
+++ b/Content.Server/Pinpointer/NavMapSystem.cs
@@ -436,12 +436,12 @@ public bool TryGetNearestBeacon(MapCoordinates coordinates,
     /// to the position of <paramref name="ent"/> from the nearest beacon.
     /// </summary>
     [PublicAPI]
-    public string GetNearestBeaconString(Entity<TransformComponent?> ent)
+    public string GetNearestBeaconString(Entity<TransformComponent?> ent, bool onlyName = false)
     {
         if (!Resolve(ent, ref ent.Comp))
             return Loc.GetString("nav-beacon-pos-no-beacons");
 
-        return GetNearestBeaconString(_transformSystem.GetMapCoordinates(ent, ent.Comp));
+        return GetNearestBeaconString(_transformSystem.GetMapCoordinates(ent, ent.Comp), onlyName);
     }
 
     /// <summary>
@@ -449,11 +449,14 @@ public string GetNearestBeaconString(Entity<TransformComponent?> ent)
     /// to <paramref name="coordinates"/> from the nearest beacon.
     /// </summary>
 
-    public string GetNearestBeaconString(MapCoordinates coordinates)
+    public string GetNearestBeaconString(MapCoordinates coordinates, bool onlyName = false)
     {
         if (!TryGetNearestBeacon(coordinates, out var beacon, out var pos))
             return Loc.GetString("nav-beacon-pos-no-beacons");
 
+        if (onlyName)
+            return beacon.Value.Comp.Text!;
+
         var gridOffset = Angle.Zero;
         if (_mapManager.TryFindGridAt(pos.Value, out var grid, out _))
             gridOffset = Transform(grid).LocalRotation;
diff --git a/Content.Shared/Materials/MaterialStorageComponent.cs b/Content.Shared/Materials/MaterialStorageComponent.cs
index 7d8dd5c7495f1..1911b3de6f081 100644
--- a/Content.Shared/Materials/MaterialStorageComponent.cs
+++ b/Content.Shared/Materials/MaterialStorageComponent.cs
@@ -75,6 +75,24 @@ public enum MaterialStorageVisuals : byte
     Inserting
 }
 
+/// <summary>
+/// Collects all the materials stored on a <see cref="MaterialStorageComponent"/>
+/// </summary>
+/// <param name="Entity">The entity holding all these materials</param>
+/// <param name="Materials">A dictionary of all materials held</param>
+/// <param name="LocalOnly">An optional specifier. Non-local sources (silo, etc.) should not add materials when this is false.</param>
+[ByRefEvent]
+public readonly record struct GetStoredMaterialsEvent(Entity<MaterialStorageComponent> Entity, Dictionary<ProtoId<MaterialPrototype>, int> Materials, bool LocalOnly);
+
+/// <summary>
+/// After using materials, removes them from storage.
+/// </summary>
+/// <param name="Entity">The entity that held the materials and is being used up</param>
+/// <param name="Materials">A dictionary of the difference of materials left.</param>
+/// <param name="LocalOnly">An optional specifier. Non-local sources (silo, etc.) should not consume materials when this is false.</param>
+[ByRefEvent]
+public readonly record struct ConsumeStoredMaterialsEvent(Entity<MaterialStorageComponent> Entity, Dictionary<ProtoId<MaterialPrototype>, int> Materials, bool LocalOnly);
+
 /// <summary>
 /// event raised on the materialStorage when a material entity is inserted into it.
 /// </summary>
diff --git a/Content.Shared/Materials/OreSilo/OreSiloClientComponent.cs b/Content.Shared/Materials/OreSilo/OreSiloClientComponent.cs
new file mode 100644
index 0000000000000..ff43c9c05aa78
--- /dev/null
+++ b/Content.Shared/Materials/OreSilo/OreSiloClientComponent.cs
@@ -0,0 +1,18 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Materials.OreSilo;
+
+/// <summary>
+/// An entity with <see cref="MaterialStorageComponent"/> that interfaces with an <see cref="OreSiloComponent"/>.
+/// Used for tracking the connected silo.
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SharedOreSiloSystem))]
+public sealed partial class OreSiloClientComponent : Component
+{
+    /// <summary>
+    /// The silo that this client pulls materials from.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public EntityUid? Silo;
+}
diff --git a/Content.Shared/Materials/OreSilo/OreSiloComponent.cs b/Content.Shared/Materials/OreSilo/OreSiloComponent.cs
new file mode 100644
index 0000000000000..45c86b18456a5
--- /dev/null
+++ b/Content.Shared/Materials/OreSilo/OreSiloComponent.cs
@@ -0,0 +1,55 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Materials.OreSilo;
+
+/// <summary>
+/// Provides additional materials to linked clients across long distances.
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SharedOreSiloSystem))]
+public sealed partial class OreSiloComponent : Component
+{
+    /// <summary>
+    /// The <see cref="OreSiloClientComponent"/> that are connected to this silo.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public HashSet<EntityUid> Clients = new();
+
+    /// <summary>
+    /// The maximum distance you can be to the silo and still receive transmission.
+    /// </summary>
+    /// <remarks>
+    /// Default value should be big enough to span a single large department.
+    /// </remarks>
+    [DataField, AutoNetworkedField]
+    public float Range = 20f;
+}
+
+[Serializable, NetSerializable]
+public sealed class OreSiloBuiState : BoundUserInterfaceState
+{
+    public readonly HashSet<(NetEntity, string, string)> Clients;
+
+    public OreSiloBuiState(HashSet<(NetEntity, string, string)> clients)
+    {
+        Clients = clients;
+    }
+}
+
+[Serializable, NetSerializable]
+public sealed class ToggleOreSiloClientMessage : BoundUserInterfaceMessage
+{
+    public readonly NetEntity Client;
+
+    public ToggleOreSiloClientMessage(NetEntity client)
+    {
+        Client = client;
+    }
+}
+
+[Serializable, NetSerializable]
+public enum OreSiloUiKey : byte
+{
+    Key
+}
diff --git a/Content.Shared/Materials/OreSilo/SharedOreSiloSystem.cs b/Content.Shared/Materials/OreSilo/SharedOreSiloSystem.cs
new file mode 100644
index 0000000000000..729a71006510a
--- /dev/null
+++ b/Content.Shared/Materials/OreSilo/SharedOreSiloSystem.cs
@@ -0,0 +1,168 @@
+using Content.Shared.Power.EntitySystems;
+using JetBrains.Annotations;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Materials.OreSilo;
+
+public abstract class SharedOreSiloSystem : EntitySystem
+{
+    [Dependency] private readonly SharedMaterialStorageSystem _materialStorage = default!;
+    [Dependency] private readonly SharedPowerReceiverSystem _powerReceiver = default!;
+    [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+    private EntityQuery<OreSiloClientComponent> _clientQuery;
+
+    /// <inheritdoc/>
+    public override void Initialize()
+    {
+        SubscribeLocalEvent<OreSiloComponent, ToggleOreSiloClientMessage>(OnToggleOreSiloClient);
+        SubscribeLocalEvent<OreSiloComponent, ComponentShutdown>(OnSiloShutdown);
+        Subs.BuiEvents<OreSiloComponent>(OreSiloUiKey.Key,
+            subs =>
+        {
+            subs.Event<BoundUIOpenedEvent>(OnBoundUIOpened);
+        });
+
+
+        SubscribeLocalEvent<OreSiloClientComponent, GetStoredMaterialsEvent>(OnGetStoredMaterials);
+        SubscribeLocalEvent<OreSiloClientComponent, ConsumeStoredMaterialsEvent>(OnConsumeStoredMaterials);
+        SubscribeLocalEvent<OreSiloClientComponent, ComponentShutdown>(OnClientShutdown);
+
+        _clientQuery = GetEntityQuery<OreSiloClientComponent>();
+    }
+
+    private void OnToggleOreSiloClient(Entity<OreSiloComponent> ent, ref ToggleOreSiloClientMessage args)
+    {
+        var client = GetEntity(args.Client);
+
+        if (!_clientQuery.TryComp(client, out var clientComp))
+            return;
+
+        if (ent.Comp.Clients.Contains(client)) // remove client
+        {
+            clientComp.Silo = null;
+            Dirty(client, clientComp);
+            ent.Comp.Clients.Remove(client);
+            Dirty(ent);
+
+            UpdateOreSiloUi(ent);
+        }
+        else // add client
+        {
+            if (!CanTransmitMaterials((ent, ent), client))
+                return;
+
+            var clientMats = _materialStorage.GetStoredMaterials(client, true);
+            var inverseMats = new Dictionary<string, int>();
+            foreach (var (mat, amount) in clientMats)
+            {
+                inverseMats.Add(mat, -amount);
+            }
+            _materialStorage.TryChangeMaterialAmount(client, inverseMats, localOnly: true);
+            _materialStorage.TryChangeMaterialAmount(ent.Owner, clientMats);
+
+            ent.Comp.Clients.Add(client);
+            Dirty(ent);
+            clientComp.Silo = ent;
+            Dirty(client, clientComp);
+
+            UpdateOreSiloUi(ent);
+        }
+    }
+
+    private void OnBoundUIOpened(Entity<OreSiloComponent> ent, ref BoundUIOpenedEvent args)
+    {
+        UpdateOreSiloUi(ent);
+    }
+
+    private void OnSiloShutdown(Entity<OreSiloComponent> ent, ref ComponentShutdown args)
+    {
+        foreach (var client in ent.Comp.Clients)
+        {
+            if (!_clientQuery.TryComp(client, out var comp))
+                continue;
+
+            comp.Silo = null;
+            Dirty(client, comp);
+        }
+    }
+
+    protected virtual void UpdateOreSiloUi(Entity<OreSiloComponent> ent)
+    {
+
+    }
+
+    private void OnGetStoredMaterials(Entity<OreSiloClientComponent> ent, ref GetStoredMaterialsEvent args)
+    {
+        if (args.LocalOnly)
+            return;
+
+        if (ent.Comp.Silo is not { } silo)
+            return;
+
+        if (!CanTransmitMaterials(silo, ent))
+            return;
+
+        var materials = _materialStorage.GetStoredMaterials(silo);
+
+        foreach (var (mat, amount) in materials)
+        {
+            // Don't supply materials that they don't usually have access to.
+            if (!_materialStorage.IsMaterialWhitelisted((args.Entity, args.Entity), mat))
+                continue;
+
+            var existing = args.Materials.GetOrNew(mat);
+            args.Materials[mat] = existing + amount;
+        }
+    }
+
+    private void OnConsumeStoredMaterials(Entity<OreSiloClientComponent> ent, ref ConsumeStoredMaterialsEvent args)
+    {
+        if (args.LocalOnly)
+            return;
+
+        if (ent.Comp.Silo is not { } silo || !TryComp<MaterialStorageComponent>(silo, out var materialStorage))
+            return;
+
+        if (!CanTransmitMaterials(silo, ent))
+            return;
+
+        foreach (var (mat, amount) in args.Materials)
+        {
+            if (!_materialStorage.TryChangeMaterialAmount(silo, mat, amount, materialStorage))
+                continue;
+            args.Materials[mat] = 0;
+        }
+    }
+
+    private void OnClientShutdown(Entity<OreSiloClientComponent> ent, ref ComponentShutdown args)
+    {
+        if (!TryComp<OreSiloComponent>(ent.Comp.Silo, out var silo))
+            return;
+
+        silo.Clients.Remove(ent);
+        Dirty(ent.Comp.Silo.Value, silo);
+        UpdateOreSiloUi((ent.Comp.Silo.Value, silo));
+    }
+
+    /// <summary>
+    /// Checks if a given client fulfills the criteria to link/receive materials from an ore silo.
+    /// </summary>
+    [PublicAPI]
+    public bool CanTransmitMaterials(Entity<OreSiloComponent?> silo, EntityUid client)
+    {
+        if (!Resolve(silo, ref silo.Comp))
+            return false;
+
+        if (!_powerReceiver.IsPowered(silo.Owner))
+            return false;
+
+        if (_transform.GetGrid(client) != _transform.GetGrid(silo.Owner))
+            return false;
+
+        if (!_transform.InRange(silo.Owner, client, silo.Comp.Range))
+            return false;
+
+        return true;
+    }
+}
diff --git a/Content.Shared/Materials/SharedMaterialStorageSystem.cs b/Content.Shared/Materials/SharedMaterialStorageSystem.cs
index 2544aacadd957..2fc03dd997e1f 100644
--- a/Content.Shared/Materials/SharedMaterialStorageSystem.cs
+++ b/Content.Shared/Materials/SharedMaterialStorageSystem.cs
@@ -1,7 +1,6 @@
 using System.Linq;
 using Content.Shared.Interaction;
 using Content.Shared.Interaction.Components;
-using Content.Shared.Mobs;
 using Content.Shared.Stacks;
 using Content.Shared.Whitelist;
 using JetBrains.Annotations;
@@ -57,17 +56,37 @@ private void OnMapInit(EntityUid uid, MaterialStorageComponent component, MapIni
         _appearance.SetData(uid, MaterialStorageVisuals.Inserting, false);
     }
 
+    /// <summary>
+    /// Gets all the materials stored on this entity
+    /// </summary>
+    /// <param name="ent"></param>
+    /// <param name="localOnly">Include only materials held "locally", as determined by event subscribers</param>
+    /// <returns></returns>
+    public Dictionary<ProtoId<MaterialPrototype>, int> GetStoredMaterials(Entity<MaterialStorageComponent?> ent, bool localOnly = false)
+    {
+        if (!Resolve(ent, ref ent.Comp, false))
+            return new();
+
+        // clone so we don't modify by accident.
+        var mats = new Dictionary<ProtoId<MaterialPrototype>, int>(ent.Comp.Storage);
+        var ev = new GetStoredMaterialsEvent((ent, ent.Comp), mats, localOnly);
+        RaiseLocalEvent(ent, ref ev, true);
+
+        return ev.Materials;
+    }
+
     /// <summary>
     /// Gets the volume of a specified material contained in this storage.
     /// </summary>
     /// <param name="uid"></param>
     /// <param name="material"></param>
     /// <param name="component"></param>
+    /// <param name="localOnly"></param>
     /// <returns>The volume of the material</returns>
     [PublicAPI]
-    public int GetMaterialAmount(EntityUid uid, MaterialPrototype material, MaterialStorageComponent? component = null)
+    public int GetMaterialAmount(EntityUid uid, MaterialPrototype material, MaterialStorageComponent? component = null, bool localOnly = false)
     {
-        return GetMaterialAmount(uid, material.ID, component);
+        return GetMaterialAmount(uid, material.ID, component, localOnly);
     }
 
     /// <summary>
@@ -76,12 +95,13 @@ public int GetMaterialAmount(EntityUid uid, MaterialPrototype material, Material
     /// <param name="uid"></param>
     /// <param name="material"></param>
     /// <param name="component"></param>
+    /// <param name="localOnly"></param>
     /// <returns>The volume of the material</returns>
-    public int GetMaterialAmount(EntityUid uid, string material, MaterialStorageComponent? component = null)
+    public int GetMaterialAmount(EntityUid uid, string material, MaterialStorageComponent? component = null, bool localOnly = false)
     {
         if (!Resolve(uid, ref component))
             return 0; //you have nothing
-        return component.Storage.GetValueOrDefault(material, 0);
+        return GetStoredMaterials((uid, component), localOnly).GetValueOrDefault(material, 0);
     }
 
     /// <summary>
@@ -89,26 +109,43 @@ public int GetMaterialAmount(EntityUid uid, string material, MaterialStorageComp
     /// </summary>
     /// <param name="uid"></param>
     /// <param name="component"></param>
+    /// <param name="localOnly"></param>
     /// <returns>The volume of all materials in the storage</returns>
-    public int GetTotalMaterialAmount(EntityUid uid, MaterialStorageComponent? component = null)
+    public int GetTotalMaterialAmount(EntityUid uid, MaterialStorageComponent? component = null, bool localOnly = false)
     {
         if (!Resolve(uid, ref component))
             return 0;
-        return component.Storage.Values.Sum();
+        return GetStoredMaterials((uid, component), localOnly).Values.Sum();
     }
 
+    // TODO: Revisit this if we ever decide to do things with storage limits. As it stands, the feature is unused.
     /// <summary>
     /// Tests if a specific amount of volume will fit in the storage.
     /// </summary>
     /// <param name="uid"></param>
     /// <param name="volume"></param>
     /// <param name="component"></param>
+    /// <param name="localOnly"></param>
     /// <returns>If the specified volume will fit</returns>
-    public bool CanTakeVolume(EntityUid uid, int volume, MaterialStorageComponent? component = null)
+    public bool CanTakeVolume(EntityUid uid, int volume, MaterialStorageComponent? component = null, bool localOnly = false)
     {
         if (!Resolve(uid, ref component))
             return false;
-        return component.StorageLimit == null || GetTotalMaterialAmount(uid, component) + volume <= component.StorageLimit;
+        return component.StorageLimit == null || GetTotalMaterialAmount(uid, component, true) + volume <= component.StorageLimit;
+    }
+
+    /// <summary>
+    /// Checks if a certain material prototype is supported by this entity.
+    /// </summary>
+    public bool IsMaterialWhitelisted(Entity<MaterialStorageComponent?> ent, ProtoId<MaterialPrototype> material)
+    {
+        if (!Resolve(ent, ref ent.Comp))
+            return false;
+
+        if (ent.Comp.MaterialWhiteList == null)
+            return true;
+
+        return ent.Comp.MaterialWhiteList.Contains(material);
     }
 
     /// <summary>
@@ -118,8 +155,9 @@ public bool CanTakeVolume(EntityUid uid, int volume, MaterialStorageComponent? c
     /// <param name="materialId"></param>
     /// <param name="volume"></param>
     /// <param name="component"></param>
+    /// <param name="localOnly"></param>
     /// <returns>If the amount can be changed</returns>
-    public bool CanChangeMaterialAmount(EntityUid uid, string materialId, int volume, MaterialStorageComponent? component = null)
+    public bool CanChangeMaterialAmount(EntityUid uid, string materialId, int volume, MaterialStorageComponent? component = null, bool localOnly = false)
     {
         if (!Resolve(uid, ref component))
             return false;
@@ -127,10 +165,10 @@ public bool CanChangeMaterialAmount(EntityUid uid, string materialId, int volume
         if (!CanTakeVolume(uid, volume, component))
             return false;
 
-        if (component.MaterialWhiteList == null ? false : !component.MaterialWhiteList.Contains(materialId))
+        if (!IsMaterialWhitelisted((uid, component), materialId))
             return false;
 
-        var amount = component.Storage.GetValueOrDefault(materialId);
+        var amount = GetMaterialAmount(uid, materialId, component, localOnly);
         return amount + volume >= 0;
     }
 
@@ -140,14 +178,24 @@ public bool CanChangeMaterialAmount(EntityUid uid, string materialId, int volume
     /// <param name="entity"></param>
     /// <param name="materials"></param>
     /// <returns>If the amount can be changed</returns>
-    public bool CanChangeMaterialAmount(Entity<MaterialStorageComponent?> entity, Dictionary<string,int> materials)
+    /// <param name="localOnly"></param>
+    public bool CanChangeMaterialAmount(Entity<MaterialStorageComponent?> entity, Dictionary<string,int> materials, bool localOnly = false)
     {
         if (!Resolve(entity, ref entity.Comp))
             return false;
 
+        var inVolume = materials.Values.Sum();
+        var stored = GetStoredMaterials((entity, entity.Comp), localOnly);
+
+        if (!CanTakeVolume(entity, inVolume, entity.Comp))
+            return false;
+
         foreach (var (material, amount) in materials)
         {
-            if (!CanChangeMaterialAmount(entity, material, amount, entity.Comp))
+            if (!IsMaterialWhitelisted(entity, material))
+                return false;
+
+            if (stored.GetValueOrDefault(material) + amount < 0)
                 return false;
         }
 
@@ -163,16 +211,27 @@ public bool CanChangeMaterialAmount(Entity<MaterialStorageComponent?> entity, Di
     /// <param name="volume"></param>
     /// <param name="component"></param>
     /// <param name="dirty"></param>
+    /// <param name="localOnly"></param>
     /// <returns>If it was successful</returns>
-    public bool TryChangeMaterialAmount(EntityUid uid, string materialId, int volume, MaterialStorageComponent? component = null, bool dirty = true)
+    public bool TryChangeMaterialAmount(EntityUid uid, string materialId, int volume, MaterialStorageComponent? component = null, bool dirty = true, bool localOnly = false)
     {
         if (!Resolve(uid, ref component))
             return false;
-        if (!CanChangeMaterialAmount(uid, materialId, volume, component))
+
+        if (!CanChangeMaterialAmount(uid, materialId, volume, component, localOnly))
             return false;
 
+        var changeEv = new ConsumeStoredMaterialsEvent((uid, component), new() {{materialId, volume}}, localOnly);
+        RaiseLocalEvent(uid, ref changeEv);
+        var remaining = changeEv.Materials.Values.First();
+
         var existing = component.Storage.GetOrNew(materialId);
-        existing += volume;
+
+        var localUpperLimit = component.StorageLimit == null ? int.MaxValue : component.StorageLimit.Value - existing;
+        var localLowerLimit = -existing;
+        var localChange = Math.Clamp(remaining, localLowerLimit, localUpperLimit);
+
+        existing += localChange;
 
         if (existing == 0)
             component.Storage.Remove(materialId);
@@ -191,23 +250,54 @@ public bool TryChangeMaterialAmount(EntityUid uid, string materialId, int volume
     /// Changes the amount of a specific material in the storage.
     /// Still respects the filters in place.
     /// </summary>
-    /// <param name="entity"></param>
-    /// <param name="materials"></param>
     /// <returns>If the amount can be changed</returns>
-    public bool TryChangeMaterialAmount(Entity<MaterialStorageComponent?> entity, Dictionary<string,int> materials)
+    public bool TryChangeMaterialAmount(Entity<MaterialStorageComponent?> entity, Dictionary<string, int> materials, bool localOnly = false)
     {
-        if (!Resolve(entity, ref entity.Comp))
-            return false;
+        return TryChangeMaterialAmount(entity, materials.Select(p => (new ProtoId<MaterialPrototype>(p.Key), p.Value)).ToDictionary(), localOnly);
+    }
 
-        if (!CanChangeMaterialAmount(entity, materials))
+    /// <summary>
+    /// Changes the amount of a specific material in the storage.
+    /// Still respects the filters in place.
+    /// </summary>
+    /// <returns>If the amount can be changed</returns>
+    public bool TryChangeMaterialAmount(
+        Entity<MaterialStorageComponent?> entity,
+        Dictionary<ProtoId<MaterialPrototype>, int> materials,
+        bool localOnly = false)
+    {
+        if (!Resolve(entity, ref entity.Comp))
             return false;
 
         foreach (var (material, amount) in materials)
         {
-            if (!TryChangeMaterialAmount(entity, material, amount, entity.Comp, false))
+            if (!CanChangeMaterialAmount(entity, material, amount, entity))
                 return false;
         }
 
+        var changeEv = new ConsumeStoredMaterialsEvent((entity, entity.Comp), materials, localOnly);
+        RaiseLocalEvent(entity, ref changeEv);
+
+        foreach (var (material, remaining) in changeEv.Materials)
+        {
+            var existing = entity.Comp.Storage.GetOrNew(material);
+
+            var localUpperLimit = entity.Comp.StorageLimit == null ? int.MaxValue : entity.Comp.StorageLimit.Value - existing;
+            var localLowerLimit = -existing;
+            var localChange = Math.Clamp(remaining, localLowerLimit, localUpperLimit);
+
+            existing += localChange;
+
+            if (existing == 0)
+                entity.Comp.Storage.Remove(material);
+            else
+                entity.Comp.Storage[material] = existing;
+
+        }
+
+        var ev = new MaterialAmountChangedEvent();
+        RaiseLocalEvent(entity, ref ev);
+
         Dirty(entity, entity.Comp);
         return true;
     }
@@ -221,6 +311,7 @@ public bool TryChangeMaterialAmount(Entity<MaterialStorageComponent?> entity, Di
     /// <param name="volume">The stored material volume to set the storage to.</param>
     /// <param name="component">The storage component on <paramref name="uid"/>. Resolved automatically if not given.</param>
     /// <returns>True if it was successful (enough space etc).</returns>
+    [PublicAPI]
     public bool TrySetMaterialAmount(
         EntityUid uid,
         string materialId,
@@ -268,7 +359,7 @@ public virtual bool TryInsertMaterialEntity(EntityUid user,
             totalVolume += vol * multiplier;
         }
 
-        if (!CanTakeVolume(receiver, totalVolume, storage))
+        if (!CanTakeVolume(receiver, totalVolume, storage, localOnly: true))
             return false;
 
         foreach (var (mat, vol) in composition.MaterialComposition)
diff --git a/Resources/Locale/en-US/lathe/ui/lathe-menu.ftl b/Resources/Locale/en-US/lathe/ui/lathe-menu.ftl
index 907baf4ffc295..076a70447cbf1 100644
--- a/Resources/Locale/en-US/lathe/ui/lathe-menu.ftl
+++ b/Resources/Locale/en-US/lathe/ui/lathe-menu.ftl
@@ -25,6 +25,7 @@ lathe-menu-material-amount-missing = { $amount ->
     *[other] {NATURALFIXED($amount, 2)} {MAKEPLURAL($unit)} of {$material} ([color=red]{NATURALFIXED($missingAmount, 2)} {MAKEPLURAL($unit)} missing[/color])
 }
 lathe-menu-no-materials-message = No materials loaded.
+lathe-menu-silo-linked-message = Silo Linked
 lathe-menu-fabricating-message = Fabricating...
 lathe-menu-materials-title = Materials
 lathe-menu-queue-title = Build Queue
diff --git a/Resources/Locale/en-US/materials/silo.ftl b/Resources/Locale/en-US/materials/silo.ftl
new file mode 100644
index 0000000000000..32d7de8b2e4ca
--- /dev/null
+++ b/Resources/Locale/en-US/materials/silo.ftl
@@ -0,0 +1,10 @@
+ore-silo-ui-title = Material Silo
+ore-silo-ui-label-clients = Machines
+ore-silo-ui-label-mats = Materials
+ore-silo-ui-itemlist-entry = {$linked ->
+    [true] {"[Linked] "}
+    *[False] {""}
+} {$name} ({$beacon}) {$inRange ->
+    [true] {""}
+    *[false] (Out of Range)
+}
diff --git a/Resources/Prototypes/Catalog/Cargo/cargo_materials.yml b/Resources/Prototypes/Catalog/Cargo/cargo_materials.yml
index 097a11185bea9..35830900a8757 100644
--- a/Resources/Prototypes/Catalog/Cargo/cargo_materials.yml
+++ b/Resources/Prototypes/Catalog/Cargo/cargo_materials.yml
@@ -98,6 +98,16 @@
   category: cargoproduct-category-name-materials
   group: market
 
+- type: cargoProduct
+  id: MaterialSilo
+  icon:
+    sprite: Structures/Machines/silo.rsi
+    state: silo
+  product: CrateMaterialSilo
+  cost: 5000
+  category: cargoproduct-category-name-materials
+  group: market
+
 - type: cargoProduct
   id: MaterialFuelTank
   icon:
diff --git a/Resources/Prototypes/Catalog/Fills/Crates/materials.yml b/Resources/Prototypes/Catalog/Fills/Crates/materials.yml
index f6eb9a17af7e3..80c6311f1321c 100644
--- a/Resources/Prototypes/Catalog/Fills/Crates/materials.yml
+++ b/Resources/Prototypes/Catalog/Fills/Crates/materials.yml
@@ -153,6 +153,22 @@
           # for some reason, the selector here adds 1 to whatever value it generates,
           # so this is actually 2-4
 
+- type: entity
+  id: CrateMaterialSilo
+  parent: CrateGenericSteel
+  name: material silo crate
+  description: A package including all the materials to create a material silo.
+  components:
+  - type: StorageFill
+    contents:
+    - id: MaterialSiloMachineCircuitboard
+    - id: SheetSteel1
+      amount: 5
+    - id: MatterBinStockPart
+      amount: 4
+    - id: CableApcStack1
+      amount: 2
+
 - type: entity
   id: CrateMaterialBasicResource
   parent: CrateGenericSteel
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
index 812b495ee96c5..48af55da3fa80 100644
--- a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
+++ b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/production.yml
@@ -1005,6 +1005,19 @@
       Manipulator: 1
       Steel: 1
 
+- type: entity
+  id: MaterialSiloMachineCircuitboard
+  parent: BaseMachineCircuitboard
+  name: material silo machine board
+  components:
+  - type: Sprite
+    state: supply
+  - type: MachineBoard
+    prototype: MachineMaterialSilo
+    stackRequirements:
+      MatterBin: 4
+      Cable: 1
+
 - type: entity
   id: OreProcessorMachineCircuitboard
   parent: BaseMachineCircuitboard
diff --git a/Resources/Prototypes/Entities/Structures/Machines/anomaly_equipment.yml b/Resources/Prototypes/Entities/Structures/Machines/anomaly_equipment.yml
index 1761f176da792..6ef8f7262f648 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/anomaly_equipment.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/anomaly_equipment.yml
@@ -270,6 +270,7 @@
         - Sheet
     materialWhiteList:
     - Plasma
+  - type: OreSiloClient
   - type: Fixtures
     fixtures:
       fix1:
diff --git a/Resources/Prototypes/Entities/Structures/Machines/flatpacker.yml b/Resources/Prototypes/Entities/Structures/Machines/flatpacker.yml
index 78f150400306a..f7f1bcba1b1c3 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/flatpacker.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/flatpacker.yml
@@ -44,6 +44,7 @@
       - Sheet
       - RawMaterial
       - Ingot
+  - type: OreSiloClient
   - type: AmbientSound
     enabled: false
     volume: 5
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index 0d008ed1e175b..3b4b9cf5d2f64 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -112,6 +112,7 @@
         - Sheet
         - RawMaterial
         - Ingot
+  - type: OreSiloClient
   - type: Lathe
     idleState: icon
     runningState: building
@@ -184,6 +185,7 @@
         - Sheet
         - RawMaterial
         - Ingot
+  - type: OreSiloClient
   - type: Lathe
     idleState: icon
     runningState: building
@@ -273,6 +275,7 @@
         - Sheet
         - RawMaterial
         - Ingot
+  - type: OreSiloClient
   - type: RequireProjectileTarget
 
 - type: entity
@@ -321,6 +324,7 @@
       - Sheet
       - RawMaterial
       - Ingot
+  - type: OreSiloClient
   - type: GuideHelp
     guides:
     - Robotics
@@ -408,6 +412,7 @@
         - Sheet
         - RawMaterial
         - Ingot
+  - type: OreSiloClient
   - type: LatheAnnouncing
     channels: [Security]
 
@@ -443,6 +448,7 @@
           - Sheet
           - RawMaterial
           - Ingot
+    - type: OreSiloClient
 
 - type: entity
   id: MedicalTechFab
@@ -480,6 +486,7 @@
     board: MedicalTechFabCircuitboard
   - type: StealTarget
     stealGroup: MedicalTechFabCircuitboard
+  - type: OreSiloClient
   - type: LatheAnnouncing
     channels: [Medical]
 
diff --git a/Resources/Prototypes/Entities/Structures/Machines/silo.yml b/Resources/Prototypes/Entities/Structures/Machines/silo.yml
new file mode 100644
index 0000000000000..31bfe42572e69
--- /dev/null
+++ b/Resources/Prototypes/Entities/Structures/Machines/silo.yml
@@ -0,0 +1,61 @@
+- type: entity
+  id: MachineMaterialSilo
+  parent: [ BaseMachinePowered, ConstructibleMachine ]
+  name: material silo
+  description: An advanced machine, capable of using bluespace technology to transmit materials to nearby machines.
+  components:
+  - type: Sprite
+    sprite: Structures/Machines/silo.rsi
+    layers:
+    - state: silo
+      map: [ "base" ]
+  - type: Appearance
+  - type: GenericVisualizer
+    visuals:
+      enum.PowerDeviceVisuals.Powered:
+        base:
+          True: { state: silo_active }
+          False: { state: silo }
+  - type: OreSilo
+  - type: MaterialStorage
+    whitelist:
+      tags:
+      - Sheet
+      - Ingot
+  - type: ActivatableUI
+    key: enum.OreSiloUiKey.Key
+  - type: ActivatableUIRequiresPower
+  - type: UserInterface
+    interfaces:
+      enum.OreSiloUiKey.Key:
+        type: OreSiloBoundUserInterface
+  - type: Machine
+    board: MaterialSiloMachineCircuitboard
+  - type: Fixtures
+    fixtures:
+      fix1:
+        shape:
+          !type:PhysShapeAabb
+          bounds: "-0.4,-0.4,0.4,0.4"
+        density: 190
+        mask:
+        - MachineMask
+        layer:
+        - MachineLayer
+  - type: Destructible
+    thresholds:
+    - trigger:
+        !type:DamageTrigger
+        damage: 300
+      behaviors:
+      - !type:PlaySoundBehavior
+        sound:
+          collection: MetalBreak
+      - !type:ChangeConstructionNodeBehavior
+        node: machineFrame
+      - !type:DoActsBehavior
+        acts: ["Destruction"]
+  - type: WiresVisuals
+  - type: WiresPanel
+  - type: StaticPrice
+    price: 1500
diff --git a/Resources/Textures/Structures/Machines/silo.rsi/meta.json b/Resources/Textures/Structures/Machines/silo.rsi/meta.json
new file mode 100644
index 0000000000000..aa78616897ff3
--- /dev/null
+++ b/Resources/Textures/Structures/Machines/silo.rsi/meta.json
@@ -0,0 +1,40 @@
+{
+    "version": 1,
+    "license": "CC-BY-SA-3.0",
+    "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/d74b67828394a9842578279a6b8ab2955bb08216. Created by MrDoomBringer (github)",
+    "size": {
+        "x": 32,
+        "y": 32
+    },
+    "states": [
+        {
+            "name": "silo"
+        },
+        {
+            "name": "silo_active",
+            "delays": [
+                [
+                    0.1,
+                    0.1,
+                    0.1,
+                    0.1,
+                    0.1,
+                    0.1
+                ]
+            ]
+        },
+        {
+            "name": "overlay_active",
+            "delays": [
+                [
+                    0.1,
+                    0.1,
+                    0.1,
+                    0.1,
+                    0.1,
+                    0.1
+                ]
+            ]
+        }
+    ]
+}
diff --git a/Resources/Textures/Structures/Machines/silo.rsi/overlay_active.png b/Resources/Textures/Structures/Machines/silo.rsi/overlay_active.png
new file mode 100644
index 0000000000000..0a06e45676e51
Binary files /dev/null and b/Resources/Textures/Structures/Machines/silo.rsi/overlay_active.png differ
diff --git a/Resources/Textures/Structures/Machines/silo.rsi/silo.png b/Resources/Textures/Structures/Machines/silo.rsi/silo.png
new file mode 100644
index 0000000000000..45fe37ac5fa42
Binary files /dev/null and b/Resources/Textures/Structures/Machines/silo.rsi/silo.png differ
diff --git a/Resources/Textures/Structures/Machines/silo.rsi/silo_active.png b/Resources/Textures/Structures/Machines/silo.rsi/silo_active.png
new file mode 100644
index 0000000000000..f25218b2d2d55
Binary files /dev/null and b/Resources/Textures/Structures/Machines/silo.rsi/silo_active.png differ


Reviews:
state: CHANGES_REQUESTED
author_association: NONE
submitted_at: 2025-04-12T17:27:48Z
user: login: EmoGarbage404
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T17:31:03Z
body: Peak
state: APPROVED
author_association: NONE
submitted_at: 2025-04-12T17:31:19Z
user: login: ScarKy0
body: Have yet to do in-game testing. Will do that later.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T20:13:17Z
user: login: ScarKy0
body: Ok so after testing theres only one nitpick I have.
Machines should have some way to tell if theyre connected to the silo, like the "Materials" text showing up as "Materials (Silo)"
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T20:55:41Z
user: login: ArtisticRoomba
body: YAML is good, see comment about attribution.

In-game testing was very nice (prediction my beloved), no material duplication glitches or anything. Some really minor issues I observed:
- You can troll someone by sticking the silo in range of a lathe and yoinking the materials from the lathe. Armory lathes for example.
- If the machine (lathe or whatever) is ever destroyed, nuked, or otherwise inaccessible, the info won't update in the linking list and it'll continue to say its linked.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-12T21:41:28Z
user: login: ScarKy0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T21:44:44Z
user: login: ArtisticRoomba
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-12T21:54:41Z
user: login: ScarKy0
body: Tested in game and I personally see no issues in the code.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T09:58:34Z
user: login: MilonPL
body: Some minor nitpicks.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T13:23:19Z
user: login: ArtisticRoomba
body: YAML/attribution side looks good to me. Still needs an in-game test from Milon.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-13T20:59:35Z
user: login: MilonPL
body: The only nitpick I have is the client ItemList jumping around whenever you select an item.

![annoying](https://github.com/user-attachments/assets/bf9e330e-6d64-4f55-8bd1-e36c1a49816a)
This is because you're calling `ClientList.Clear()` whenever updating the UI, which resets the scroll position. Don't rebuild the list on every update, instead only remove/add new items and use `ClientList.Sort()` instead to get the correct order.

![not so annoying](https://github.com/user-attachments/assets/0279c430-b5ab-4a54-b5fc-714ef9dea477)

The UI not being predicted is also pretty annoying, but I'm not sure how to approach that because of the UI update logic being on the server. 


state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:12:27Z
user: login: metalgearsloth
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:17:37Z
user: login: metalgearsloth
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:40:21Z
user: login: metalgearsloth
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T09:31:19Z
user: login: EmoGarbage404
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T22:57:15Z
user: login: metalgearsloth
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T23:08:42Z

Review comments:
path: Resources/Textures/Structures/Machines/silo.rsi/meta.json
body: The sprite is actually taken from "https://github.com/tgstation/tgstation/pull/39768/commits/d74b67828394a9842578279a6b8ab2955bb08216" and was made by MrDoomBringer
created_at: 2025-04-12T17:27:43Z
author_association: NONE
path: Resources/Textures/Structures/Machines/silo.rsi/meta.json
user: login: EmoGarbage404
body: Thanks for informing me of the provenance. I've updated the PR accordingly.
created_at: 2025-04-12T17:31:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,117 @@
+using Content.Server.Pinpointer;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Materials.OreSilo;
+using Robust.Server.GameStates;
+using Robust.Shared.Player;
+
+namespace Content.Server.Materials;
+
+/// <inheritdoc/>
+public sealed class OreSiloSystem : SharedOreSiloSystem
+{
+    [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
+    [Dependency] private readonly NavMapSystem _navMap = default!;
+    [Dependency] private readonly PvsOverrideSystem _pvsOverride = default!;
+    [Dependency] private readonly SharedUserInterfaceSystem _userInterface = default!;
+
+    private readonly HashSet<Entity<OreSiloClientComponent>> _clientLookup = new();
+
+    protected override void UpdateOreSiloUi(Entity<OreSiloComponent> ent)
+    {
+        if (!_userInterface.IsUiOpen(ent.Owner, OreSiloUiKey.Key))
+            return;
+        _clientLookup.Clear();
+
+        var clients = new HashSet<(NetEntity, string, string)>();
+        var xform = Transform(ent);
+
+        // Sneakily uses override with TComponent parameter
+        _entityLookup.GetEntitiesInRange(xform.Coordinates, ent.Comp.Range, _clientLookup);
+
+        foreach (var client in _clientLookup)
+        {
+            // don't show already-linked clients.
+            if (client.Comp.Silo is not null)
+                continue;
+
+            var netEnt = GetNetEntity(client);
+            var name = Identity.Name(client, EntityManager);
+            var beacon = _navMap.GetNearestBeaconString(client.Owner, onlyName: true);
+
+            var txt = Loc.GetString("ore-silo-ui-itemlist-entry",
+                ("name", name),
+                ("beacon", beacon),
+                ("linked", ent.Comp.Clients.Contains(client)),
+                ("inRange", true));
+
+            clients.Add((netEnt, txt, beacon));
+        }
+
+        // Get all clients of this silo, including those out of range.
+        foreach (var client in ent.Comp.Clients)
+        {
+            var netEnt = GetNetEntity(client);
+            var name = Identity.Name(client, EntityManager);
+            var beacon = _navMap.GetNearestBeaconString(client, onlyName: true);
+            var inRange = CanTransmitMaterials((ent, ent), client);
+
+            var txt = Loc.GetString("ore-silo-ui-itemlist-entry",
+                ("name", name),
+                ("beacon", beacon),
+                ("linked", ent.Comp.Clients.Contains(client)),
+                ("inRange", inRange));
+
+            clients.Add((netEnt, txt, beacon));
+        }
+
+        _userInterface.SetUiState(ent.Owner, OreSiloUiKey.Key, new OreSiloBuiState(clients));
+    }
+
+    private const float OreSiloPreloadRangeSquared = 225f; // ~1 screen
+    private readonly HashSet<EntityUid> _silosToAdd = new();
+    private readonly HashSet<EntityUid> _silosToRemove = new();
path: Content.Server/Materials/OreSiloSystem.cs
user: login: ScarKy0
body: Should be below dependancies
created_at: 2025-04-12T20:05:10Z
author_association: CONTRIBUTOR
path: Resources/Textures/Structures/Machines/silo.rsi/meta.json
user: login: ArtisticRoomba
body: File needs to be 4-spaced
created_at: 2025-04-12T21:37:45Z
author_association: MEMBER
path: Resources/Textures/Structures/Machines/silo.rsi/meta.json
user: login: ScarKy0
body: Wait we do 4-spaces and not 2-spaces?
created_at: 2025-04-12T21:44:44Z
author_association: CONTRIBUTOR
path: Resources/Textures/Structures/Machines/silo.rsi/meta.json
user: login: ArtisticRoomba
body: Dev wiki conventions give examples in 4 spaces, though they don't precisely say the spacing (the example was made by PJB though).
RSIEdit outputs in 2 spaced reportedly.
Editorconfig requests that they're 4 spaced.
A maintainer has said in the past that they're supposed to be 4 spaced.

It's really a shrug, it's just painful when rider bulldozes formatting and whatever you're working with is 2 spaced but rider wants to enforce 4 spaced formatting as per the editorconfig. Maybe I'll bring it up later in the future.
created_at: 2025-04-12T21:54:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,164 @@
+using Content.Shared.Power.EntitySystems;
+using JetBrains.Annotations;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Materials.OreSilo;
+
+public abstract class SharedOreSiloSystem : EntitySystem
+{
+    [Dependency] private readonly SharedMaterialStorageSystem _materialStorage = default!;
+    [Dependency] private readonly SharedPowerReceiverSystem _powerReceiver = default!;
+    [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+    private EntityQuery<OreSiloClientComponent> _clientQuery;
+
+    /// <inheritdoc/>
+    public override void Initialize()
+    {
+        SubscribeLocalEvent<OreSiloComponent, ToggleOreSiloClientMessage>(OnToggleOreSiloClient);
+        SubscribeLocalEvent<OreSiloComponent, BoundUIOpenedEvent>(OnBoundUIOpened);
+        SubscribeLocalEvent<OreSiloComponent, ComponentShutdown>(OnSiloShutdown);
+
+        SubscribeLocalEvent<OreSiloClientComponent, GetStoredMaterialsEvent>(OnGetStoredMaterials);
+        SubscribeLocalEvent<OreSiloClientComponent, ConsumeStoredMaterialsEvent>(OnConsumeStoredMaterials);
+        SubscribeLocalEvent<OreSiloClientComponent, ComponentShutdown>(OnClientShutdown);
+
+        _clientQuery = GetEntityQuery<OreSiloClientComponent>();
+    }
+
+    private void OnToggleOreSiloClient(Entity<OreSiloComponent> ent, ref ToggleOreSiloClientMessage args)
+    {
+        var client = GetEntity(args.Client);
+
+        if (!_clientQuery.TryComp(client, out var clientComp))
+            return;
+
+        if (ent.Comp.Clients.Contains(client)) // remove client
+        {
+            clientComp.Silo = null;
+            Dirty(client, clientComp);
+            ent.Comp.Clients.Remove(client);
+            Dirty(ent);
+
+            UpdateOreSiloUi(ent);
+        }
+        else // add client
+        {
+            if (!CanTransmitMaterials((ent, ent), client))
+                return;
+
+            var clientMats = _materialStorage.GetStoredMaterials(client, true);
+            var inverseMats = new Dictionary<string, int>();
+            foreach (var (mat, amount) in clientMats)
+            {
+                inverseMats.Add(mat, -amount);
+            }
+            _materialStorage.TryChangeMaterialAmount(client, inverseMats, localOnly: true);
+            _materialStorage.TryChangeMaterialAmount(ent.Owner, clientMats);
+
+            ent.Comp.Clients.Add(client);
+            Dirty(ent);
+            clientComp.Silo = ent;
+            Dirty(client, clientComp);
+
+            UpdateOreSiloUi(ent);
+        }
+    }
+
+    private void OnBoundUIOpened(Entity<OreSiloComponent> ent, ref BoundUIOpenedEvent args)
+    {
+        if (args.UiKey is OreSiloUiKey.Key)
path: Content.Shared/Materials/OreSilo/SharedOreSiloSystem.cs
user: login: MilonPL
body: Use Subs.BuiEvents rather than this.
created_at: 2025-04-13T13:16:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,6 +75,12 @@ public enum MaterialStorageVisuals : byte
     Inserting
 }
 
+[ByRefEvent]
+public readonly record struct GetStoredMaterialsEvent(Entity<MaterialStorageComponent> Entity, Dictionary<ProtoId<MaterialPrototype>, int> Materials, bool LocalOnly);
path: Content.Shared/Materials/MaterialStorageComponent.cs
user: login: MilonPL
body: Needs docs.
created_at: 2025-04-13T13:16:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -76,39 +95,54 @@ public int GetMaterialAmount(EntityUid uid, MaterialPrototype material, Material
     /// <param name="uid"></param>
     /// <param name="material"></param>
     /// <param name="component"></param>
+    /// <param name="localOnly"></param>
     /// <returns>The volume of the material</returns>
-    public int GetMaterialAmount(EntityUid uid, string material, MaterialStorageComponent? component = null)
+    public int GetMaterialAmount(EntityUid uid, string material, MaterialStorageComponent? component = null, bool localOnly = false)
     {
         if (!Resolve(uid, ref component))
             return 0; //you have nothing
-        return component.Storage.GetValueOrDefault(material, 0);
+        return GetStoredMaterials((uid, component), localOnly).GetValueOrDefault(material, 0);
     }
 
     /// <summary>
     /// Gets the total volume of all materials in the storage.
     /// </summary>
     /// <param name="uid"></param>
     /// <param name="component"></param>
+    /// <param name="localOnly"></param>
     /// <returns>The volume of all materials in the storage</returns>
-    public int GetTotalMaterialAmount(EntityUid uid, MaterialStorageComponent? component = null)
+    public int GetTotalMaterialAmount(EntityUid uid, MaterialStorageComponent? component = null, bool localOnly = false)
     {
         if (!Resolve(uid, ref component))
             return 0;
-        return component.Storage.Values.Sum();
+        return GetStoredMaterials((uid, component), localOnly).Values.Sum();
     }
 
+    // TODO: Revisit this if we ever decide to do things with storage limits. As it stands, the feature is unused.
     /// <summary>
     /// Tests if a specific amount of volume will fit in the storage.
     /// </summary>
     /// <param name="uid"></param>
     /// <param name="volume"></param>
     /// <param name="component"></param>
+    /// <param name="localOnly"></param>
     /// <returns>If the specified volume will fit</returns>
-    public bool CanTakeVolume(EntityUid uid, int volume, MaterialStorageComponent? component = null)
+    public bool CanTakeVolume(EntityUid uid, int volume, MaterialStorageComponent? component = null, bool localOnly = false)
     {
         if (!Resolve(uid, ref component))
             return false;
-        return component.StorageLimit == null || GetTotalMaterialAmount(uid, component) + volume <= component.StorageLimit;
+        return component.StorageLimit == null || GetTotalMaterialAmount(uid, component, true) + volume <= component.StorageLimit;
+    }
+
+    public bool IsMaterialWhitelisted(Entity<MaterialStorageComponent?> ent, ProtoId<MaterialPrototype> material)
path: Content.Shared/Materials/SharedMaterialStorageSystem.cs
user: login: MilonPL
body: Docs.
created_at: 2025-04-13T13:17:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -57,17 +56,37 @@ private void OnMapInit(EntityUid uid, MaterialStorageComponent component, MapIni
         _appearance.SetData(uid, MaterialStorageVisuals.Inserting, false);
     }
 
+    /// <summary>
+    /// Gets all the materials stored on this entity
+    /// </summary>
+    /// <param name="ent"></param>
+    /// <param name="localOnly">Include only materials held "locally", as determined by event subscribers</param>
+    /// <returns></returns>
+    public Dictionary<ProtoId<MaterialPrototype>, int>GetStoredMaterials(Entity<MaterialStorageComponent?> ent, bool localOnly = false)
path: Content.Shared/Materials/SharedMaterialStorageSystem.cs
user: login: metalgearsloth
body: ```suggestion
    /// <returns></returns>
    public Dictionary<ProtoId<MaterialPrototype>, int> GetStoredMaterials(Entity<MaterialStorageComponent?> ent, bool localOnly = false)
```
created_at: 2025-04-14T14:16:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,118 @@
+using Content.Server.Pinpointer;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Materials.OreSilo;
+using Robust.Server.GameStates;
+using Robust.Shared.Player;
+
+namespace Content.Server.Materials;
+
+/// <inheritdoc/>
+public sealed class OreSiloSystem : SharedOreSiloSystem
+{
+    [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
+    [Dependency] private readonly NavMapSystem _navMap = default!;
+    [Dependency] private readonly PvsOverrideSystem _pvsOverride = default!;
+    [Dependency] private readonly SharedUserInterfaceSystem _userInterface = default!;
+
+    private const float OreSiloPreloadRangeSquared = 225f; // ~1 screen
+
+    private readonly HashSet<Entity<OreSiloClientComponent>> _clientLookup = new();
+    private readonly HashSet<EntityUid> _silosToAdd = new();
+    private readonly HashSet<EntityUid> _silosToRemove = new();
+
+    protected override void UpdateOreSiloUi(Entity<OreSiloComponent> ent)
+    {
+        if (!_userInterface.IsUiOpen(ent.Owner, OreSiloUiKey.Key))
+            return;
+        _clientLookup.Clear();
+
+        var clients = new HashSet<(NetEntity, string, string)>();
+        var xform = Transform(ent);
+
+        // Sneakily uses override with TComponent parameter
+        _entityLookup.GetEntitiesInRange(xform.Coordinates, ent.Comp.Range, _clientLookup);
+
+        foreach (var client in _clientLookup)
+        {
+            // don't show already-linked clients.
+            if (client.Comp.Silo is not null)
+                continue;
+
+            var netEnt = GetNetEntity(client);
+            var name = Identity.Name(client, EntityManager);
+            var beacon = _navMap.GetNearestBeaconString(client.Owner, onlyName: true);
+
+            var txt = Loc.GetString("ore-silo-ui-itemlist-entry",
+                ("name", name),
+                ("beacon", beacon),
+                ("linked", ent.Comp.Clients.Contains(client)),
+                ("inRange", true));
+
+            clients.Add((netEnt, txt, beacon));
+        }
+
+        // Get all clients of this silo, including those out of range.
+        foreach (var client in ent.Comp.Clients)
+        {
+            var netEnt = GetNetEntity(client);
+            var name = Identity.Name(client, EntityManager);
+            var beacon = _navMap.GetNearestBeaconString(client, onlyName: true);
+            var inRange = CanTransmitMaterials((ent, ent), client);
+
+            var txt = Loc.GetString("ore-silo-ui-itemlist-entry",
+                ("name", name),
+                ("beacon", beacon),
+                ("linked", ent.Comp.Clients.Contains(client)),
+                ("inRange", inRange));
+
+            clients.Add((netEnt, txt, beacon));
+        }
+
+        _userInterface.SetUiState(ent.Owner, OreSiloUiKey.Key, new OreSiloBuiState(clients));
+    }
+
+    public override void Update(float frameTime)
+    {
+        base.Update(frameTime);
+
+        // Solving an annoying problem: we need to send the silo to people who are near the silo so that
+        // Things don't start wildly mispredicting. We do this as cheaply as possible via grid-based local-pos checks.
+        // Sloth okay-ed this in the interim until a better solution comes around.
+
+        var actorQuery = EntityQueryEnumerator<ActorComponent, TransformComponent>();
+        while (actorQuery.MoveNext(out _, out var actorComp, out var actorXform))
path: Content.Server/Materials/OreSiloSystem.cs
user: login: metalgearsloth
body: On engine as its own component, and should iterate all players instead.
created_at: 2025-04-14T14:17:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,118 @@
+using Content.Server.Pinpointer;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Materials.OreSilo;
+using Robust.Server.GameStates;
+using Robust.Shared.Player;
+
+namespace Content.Server.Materials;
+
+/// <inheritdoc/>
+public sealed class OreSiloSystem : SharedOreSiloSystem
+{
+    [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
+    [Dependency] private readonly NavMapSystem _navMap = default!;
+    [Dependency] private readonly PvsOverrideSystem _pvsOverride = default!;
+    [Dependency] private readonly SharedUserInterfaceSystem _userInterface = default!;
+
+    private const float OreSiloPreloadRangeSquared = 225f; // ~1 screen
+
+    private readonly HashSet<Entity<OreSiloClientComponent>> _clientLookup = new();
+    private readonly HashSet<EntityUid> _silosToAdd = new();
+    private readonly HashSet<EntityUid> _silosToRemove = new();
+
+    protected override void UpdateOreSiloUi(Entity<OreSiloComponent> ent)
+    {
+        if (!_userInterface.IsUiOpen(ent.Owner, OreSiloUiKey.Key))
+            return;
+        _clientLookup.Clear();
+
+        var clients = new HashSet<(NetEntity, string, string)>();
+        var xform = Transform(ent);
+
+        // Sneakily uses override with TComponent parameter
+        _entityLookup.GetEntitiesInRange(xform.Coordinates, ent.Comp.Range, _clientLookup);
+
+        foreach (var client in _clientLookup)
+        {
+            // don't show already-linked clients.
+            if (client.Comp.Silo is not null)
+                continue;
+
+            var netEnt = GetNetEntity(client);
+            var name = Identity.Name(client, EntityManager);
+            var beacon = _navMap.GetNearestBeaconString(client.Owner, onlyName: true);
+
+            var txt = Loc.GetString("ore-silo-ui-itemlist-entry",
+                ("name", name),
+                ("beacon", beacon),
+                ("linked", ent.Comp.Clients.Contains(client)),
+                ("inRange", true));
+
+            clients.Add((netEnt, txt, beacon));
+        }
+
+        // Get all clients of this silo, including those out of range.
+        foreach (var client in ent.Comp.Clients)
+        {
+            var netEnt = GetNetEntity(client);
+            var name = Identity.Name(client, EntityManager);
+            var beacon = _navMap.GetNearestBeaconString(client, onlyName: true);
+            var inRange = CanTransmitMaterials((ent, ent), client);
+
+            var txt = Loc.GetString("ore-silo-ui-itemlist-entry",
+                ("name", name),
+                ("beacon", beacon),
+                ("linked", ent.Comp.Clients.Contains(client)),
+                ("inRange", inRange));
+
+            clients.Add((netEnt, txt, beacon));
+        }
+
+        _userInterface.SetUiState(ent.Owner, OreSiloUiKey.Key, new OreSiloBuiState(clients));
+    }
+
+    public override void Update(float frameTime)
+    {
+        base.Update(frameTime);
+
+        // Solving an annoying problem: we need to send the silo to people who are near the silo so that
+        // Things don't start wildly mispredicting. We do this as cheaply as possible via grid-based local-pos checks.
+        // Sloth okay-ed this in the interim until a better solution comes around.
+
+        var actorQuery = EntityQueryEnumerator<ActorComponent, TransformComponent>();
+        while (actorQuery.MoveNext(out _, out var actorComp, out var actorXform))
path: Content.Server/Materials/OreSiloSystem.cs
user: login: metalgearsloth
body: Fuck it just leave it as is and set a reminder for me on the weekend
created_at: 2025-04-14T14:40:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,36 @@
+using Content.Shared.Materials.OreSilo;
+using JetBrains.Annotations;
+using Robust.Client.UserInterface;
+
+namespace Content.Client.Materials.UI;
+
+[UsedImplicitly]
+public sealed class OreSiloBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
+{
+    [ViewVariables]
+    private OreSiloMenu? _menu;
+
+    protected override void Open()
+    {
+        base.Open();
+
+        _menu = this.CreateWindow<OreSiloMenu>();
+        _menu.SetEntity(Owner);
+
+        _menu.OnClientEntryPressed += netEnt =>
+        {
+            SendPredictedMessage(new ToggleOreSiloClientMessage(netEnt));
+        };
+
+        _menu.OnClose += Close;
path: Content.Client/Materials/UI/OreSiloBoundUserInterface.cs
user: login: metalgearsloth
body: CreateWIndow does this.
created_at: 2025-04-16T08:30:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,36 @@
+using Content.Shared.Materials.OreSilo;
+using JetBrains.Annotations;
+using Robust.Client.UserInterface;
+
+namespace Content.Client.Materials.UI;
+
+[UsedImplicitly]
+public sealed class OreSiloBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
+{
+    [ViewVariables]
+    private OreSiloMenu? _menu;
+
+    protected override void Open()
+    {
+        base.Open();
+
+        _menu = this.CreateWindow<OreSiloMenu>();
+        _menu.SetEntity(Owner);
+
+        _menu.OnClientEntryPressed += netEnt =>
+        {
+            SendPredictedMessage(new ToggleOreSiloClientMessage(netEnt));
+        };
+
+        _menu.OnClose += Close;
+    }
+
+    protected override void UpdateState(BoundUserInterfaceState state)
+    {
+        base.UpdateState(state);
+
+        if (state is not OreSiloBuiState msg)
+            return;
+        _menu?.Update(msg);
+    }
path: Content.Client/Materials/UI/OreSiloBoundUserInterface.cs
user: login: metalgearsloth
body: Thought I had a generic version of this but what I actually made was an Update<T> method that systems can call that calls this and the Update virtual method.
created_at: 2025-04-16T08:32:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,65 @@
+using System.Linq;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Materials.OreSilo;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Materials.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class OreSiloMenu : FancyWindow
+{
+    public event Action<NetEntity>? OnClientEntryPressed;
+
+    public OreSiloMenu()
+    {
+        RobustXamlLoader.Load(this);
+
+        ClientList.OnItemSelected += args =>
+        {
+            var item = ClientList[args.ItemIndex];
+            // a little bit of null suppression makes me feel great! :-)
+            OnClientEntryPressed?.Invoke((NetEntity) item.Metadata!);
+        };
+    }
+
+    public void SetEntity(EntityUid uid)
+    {
+        Materials.SetOwner(uid);
+    }
+
+    public void Update(OreSiloBuiState state)
+    {
+        var items = new List<ItemList.Item>();
+        var orderedClients = state.Clients.OrderBy(t => t.Item3).ThenBy(t => t.Item1.Id);
+        foreach (var (ent, _, _) in orderedClients)
+        {
+            items.Add(new ItemList.Item(ClientList)
+            {
+                Metadata = ent
+            });
+        }
+
+        ClientList.SetItems(items,
+            (item1, item2) =>
+            {
+                var ent1 = (NetEntity) item1.Metadata!;
+                var ent2 = (NetEntity) item2.Metadata!;
+                return ent1.CompareTo(ent2);
+            });
+
+        var entTextDict = state.Clients.Select(t => (t.Item1, t.Item2)).ToDictionary();
+        var enumerator = ClientList.GetEnumerator();
+        while (enumerator.MoveNext())
+        {
+            if (enumerator.Current.Metadata is not NetEntity ent)
+                continue;
+
+            if (entTextDict.TryGetValue(ent, out var text))
+                enumerator.Current.Text = text;
+        }
path: Content.Client/Materials/UI/OreSiloMenu.xaml.cs
user: login: metalgearsloth
body: using var on the enumerator?
created_at: 2025-04-16T08:33:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,118 @@
+using Content.Server.Pinpointer;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Materials.OreSilo;
+using Robust.Server.GameStates;
+using Robust.Shared.Player;
+
+namespace Content.Server.Materials;
+
+/// <inheritdoc/>
+public sealed class OreSiloSystem : SharedOreSiloSystem
+{
+    [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
+    [Dependency] private readonly NavMapSystem _navMap = default!;
+    [Dependency] private readonly PvsOverrideSystem _pvsOverride = default!;
+    [Dependency] private readonly SharedUserInterfaceSystem _userInterface = default!;
+
+    private const float OreSiloPreloadRangeSquared = 225f; // ~1 screen
+
+    private readonly HashSet<Entity<OreSiloClientComponent>> _clientLookup = new();
+    private readonly HashSet<EntityUid> _silosToAdd = new();
+    private readonly HashSet<EntityUid> _silosToRemove = new();
+
+    protected override void UpdateOreSiloUi(Entity<OreSiloComponent> ent)
+    {
+        if (!_userInterface.IsUiOpen(ent.Owner, OreSiloUiKey.Key))
+            return;
+        _clientLookup.Clear();
+
+        var clients = new HashSet<(NetEntity, string, string)>();
path: Content.Server/Materials/OreSiloSystem.cs
user: login: metalgearsloth
body: Kid name put on field on system.
created_at: 2025-04-16T08:34:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,118 @@
+using Content.Server.Pinpointer;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Materials.OreSilo;
+using Robust.Server.GameStates;
+using Robust.Shared.Player;
+
+namespace Content.Server.Materials;
+
+/// <inheritdoc/>
+public sealed class OreSiloSystem : SharedOreSiloSystem
+{
+    [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
+    [Dependency] private readonly NavMapSystem _navMap = default!;
+    [Dependency] private readonly PvsOverrideSystem _pvsOverride = default!;
+    [Dependency] private readonly SharedUserInterfaceSystem _userInterface = default!;
+
+    private const float OreSiloPreloadRangeSquared = 225f; // ~1 screen
+
+    private readonly HashSet<Entity<OreSiloClientComponent>> _clientLookup = new();
+    private readonly HashSet<EntityUid> _silosToAdd = new();
+    private readonly HashSet<EntityUid> _silosToRemove = new();
+
+    protected override void UpdateOreSiloUi(Entity<OreSiloComponent> ent)
+    {
+        if (!_userInterface.IsUiOpen(ent.Owner, OreSiloUiKey.Key))
+            return;
+        _clientLookup.Clear();
+
+        var clients = new HashSet<(NetEntity, string, string)>();
+        var xform = Transform(ent);
+
+        // Sneakily uses override with TComponent parameter
+        _entityLookup.GetEntitiesInRange(xform.Coordinates, ent.Comp.Range, _clientLookup);
path: Content.Server/Materials/OreSiloSystem.cs
user: login: metalgearsloth
body: If there's enough silos this starts doing actual lookups instead which could get expensive.

Also I realised for viewersubs I should probably use comptree for lookups for that so note that for me.
created_at: 2025-04-16T08:35:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,118 @@
+using Content.Server.Pinpointer;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Materials.OreSilo;
+using Robust.Server.GameStates;
+using Robust.Shared.Player;
+
+namespace Content.Server.Materials;
+
+/// <inheritdoc/>
+public sealed class OreSiloSystem : SharedOreSiloSystem
+{
+    [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
+    [Dependency] private readonly NavMapSystem _navMap = default!;
+    [Dependency] private readonly PvsOverrideSystem _pvsOverride = default!;
+    [Dependency] private readonly SharedUserInterfaceSystem _userInterface = default!;
+
+    private const float OreSiloPreloadRangeSquared = 225f; // ~1 screen
+
+    private readonly HashSet<Entity<OreSiloClientComponent>> _clientLookup = new();
+    private readonly HashSet<EntityUid> _silosToAdd = new();
+    private readonly HashSet<EntityUid> _silosToRemove = new();
+
+    protected override void UpdateOreSiloUi(Entity<OreSiloComponent> ent)
+    {
+        if (!_userInterface.IsUiOpen(ent.Owner, OreSiloUiKey.Key))
+            return;
+        _clientLookup.Clear();
+
+        var clients = new HashSet<(NetEntity, string, string)>();
+        var xform = Transform(ent);
+
+        // Sneakily uses override with TComponent parameter
+        _entityLookup.GetEntitiesInRange(xform.Coordinates, ent.Comp.Range, _clientLookup);
path: Content.Server/Materials/OreSiloSystem.cs
user: login: EmoGarbage404
body: due to the nature of it i doubt you'd ever see >5 in a round.
created_at: 2025-04-18T22:57:15Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-12T17:18:55Z
body: RSI Diff Bot; head commit 993d0ab9dc9097dae08f41dd4630a7512d914495 merging into fe140a1e6ae31c2a80dc3b476e169dceae7a573a
This PR makes changes to 1 or more RSIs. Here is a summary of all changes:

<details><summary>Resources/Textures/Structures/Machines/silo.rsi</summary>
<p>

| State | Old | New | Status
| --- | --- | --- | --- |
| overlay_active | ![]() | ![](https://raw.githubusercontent.com/EmoGarbage404/space-station-14/993d0ab9dc9097dae08f41dd4630a7512d914495/Resources/Textures/Structures/Machines/silo.rsi/overlay_active.png) | Added
| silo | ![]() | ![](https://raw.githubusercontent.com/EmoGarbage404/space-station-14/993d0ab9dc9097dae08f41dd4630a7512d914495/Resources/Textures/Structures/Machines/silo.rsi/silo.png) | Added
| silo_active | ![]() | ![](https://raw.githubusercontent.com/EmoGarbage404/space-station-14/993d0ab9dc9097dae08f41dd4630a7512d914495/Resources/Textures/Structures/Machines/silo.rsi/silo_active.png) | Added


</p>
</details>
Edit: diff updated after 993d0ab9dc9097dae08f41dd4630a7512d914495
author_association: CONTRIBUTOR
user: login: lzk228
created_at: 2025-04-12T18:51:47Z
body: where do materials go if you link an autolathe with plastic to a silo with steel and then unlink them?
i think it should be somehow shown which materials are from silo and which are from a lathe
author_association: CONTRIBUTOR
user: login: EmoGarbage404
created_at: 2025-04-12T18:57:29Z
body: > where do materials go if you link an autolathe with plastic to a silo with steel and then unlink them? i think it should be somehow shown which materials are from silo and which are from a lathe

all the materials are in the silo. when you link a lathe, all the materials are moved to the silo. When you unlink it, the lathe has nothing.
author_association: CONTRIBUTOR
user: login: ScarKy0
created_at: 2025-04-13T07:23:22Z
body: I may be stupid but the "Silo Linked" label doesnt show up for me

Edit: Nevermind im just stupid
author_association: CONTRIBUTOR
user: login: IProduceWidgets
created_at: 2025-04-13T11:44:25Z
body: Magic transfer feels a tiny bit silly when we've got pipes and wires and transport tubes everywhere, not to mention conveyor belts. That said I like the factory-ization. I definitely think this is a step in that direction. 

This feels a little like it will step on the toes of other potential systems like waypointed cargomules or another infrastructure system like cargo train lines. But of course we don't have those yet, so mild grains of salt.

Mechanically this seems like it will make it slightly easier for bad actors to waste a department's materials.
I think that's maybe not the biggest concern though given that usually all the machines are placed nearby one another regardless.
I also think the station economy rework could serve to improve that issue a bit since the department will have a direct way to replenish.

I think its fine?
I might prefer a different system later though.
author_association: CONTRIBUTOR
user: login: ScarKy0
created_at: 2025-04-13T11:47:30Z
body: > Mechanically this seems like it will make it slightly easier for bad actors to waste a department's materials.
I think that's maybe not the biggest concern though given that usually all the machines are placed nearby one another regardless

Not neccessarily. You need to break to the silo to link a machine to it. So only people from that department can link them together. If you already have access to that department to begin with you can steal materials just as easily as without a silo.
Also its limited to 25 tiles range, not global like on downstreams
author_association: CONTRIBUTOR
user: login: K-Dynamic
created_at: 2025-04-13T11:54:39Z
body: > I think its fine? I might prefer a different system later though.

Factorio underground belt network with inserters 
author_association: CONTRIBUTOR
user: login: superjj18
created_at: 2025-04-13T13:08:29Z
body: Feel like you may as well do what holopads do, in the sense of having different versions such as global, map wide, grid wide, and local.  Even if you don't want to implement a station wide silo, I feel like the mere option could have value for up and downstream
author_association: NONE
user: login: ScarKy0
created_at: 2025-04-13T13:09:45Z
body: > Feel like you may as well do what holopads do, in the sense of having different versions such as global, map wide, grid wide, and local. Even if you don't want to implement a station wide silo, I feel like the mere option could have value for up and downstream

People can always modify the range in yaml
author_association: CONTRIBUTOR
user: login: superjj18
created_at: 2025-04-13T13:31:56Z
body: > > Feel like you may as well do what holopads do, in the sense of having different versions such as global, map wide, grid wide, and local. Even if you don't want to implement a station wide silo, I feel like the mere option could have value for up and downstream
> 
> 
> 
> People can always modify the range in yaml

Half my point was for admeme purposes.  Let's say the event runner wanted station at its absolute peak performance so they have centcomm say they are sending extra materials via "Blue Space Matterbeam"(patent pending) and the admin could simply use a global or map wide silo to instantly ensure the station is immediately at peak performance when it comes to materials.

I don't think any other admin tool, even the item spawner itself, could so effectively prepare the station for a threat of admeme proportions.  If the admin needs the station firing on all cylinders this is how they can do it.
author_association: NONE
user: login: K-Dynamic
created_at: 2025-04-13T14:32:48Z
body: > People can always modify the range in yaml

Does it include settings for being on the same grid or map

author_association: CONTRIBUTOR
user: login: ScarKy0
created_at: 2025-04-13T16:12:37Z
body: > Does it include settings for being on the same grid or map

No, but doing it that way would hurt the hell out of performance
author_association: CONTRIBUTOR
user: login: IProduceWidgets
created_at: 2025-04-15T03:11:21Z
body: After thinking about this further, I'm pretty sure this is going to be a mapping sin for large maps where its not at all clear these will cover the entire department. Also I think being on the edge and linking failing will be a frustrating engineering issue.

for instance:
![image](https://github.com/user-attachments/assets/07dc6c49-9952-40d8-9caa-23af57d5dd81)
(exo suit fabs bottom left, sci fabs top right)

I think we might be much better off with another "pipe" system for material providing. I know, node groups are pain though. 
<3
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-16T19:59:28Z
body: This pull request has conflicts, please resolve those before we can evaluate the pull request.
author_association: CONTRIBUTOR
user: login: ilovehans10
created_at: 2025-04-17T16:41:09Z
body: Just my two cents here, but I feel like this would work much better if all linking was done with the multitool. This would prevent tiders from easily siphoning from a silo, and I assume would help performance as you would no longer have iterate over all `OreSiloClientComponent` items and find which are in range of a silo.

> After thinking about this further, I'm pretty sure this is going to be a mapping sin for large maps where its not at all clear these will cover the entire department. Also I think being on the edge and linking failing will be a frustrating engineering issue.

Would linkable range extenders be a possible option? That way for departments that are large/split up you could bridge gaps with the power of bluespace. To keep things balanced, you could have it where the silo extender can link (via multitool) with 1 silo that is ~50 tiles away from it, and it can service an area that is 1/2 the size of a regular silo.

> I think we might be much better off with another "pipe" system for material providing. I know, node groups are pain though.
<3

If you ask me making this rely on pipes is one way to make sure that this never gets used beyond what is set up roundstart. The process of ripping up a floor, placing pipes both manually and slowly over an area and then replacing the floor (now without the pretty departmental markings) is just a pain. On top of that it's already a pain to try to find out where atmos pipes were run after a bombing, and now we will have another type of pipes obfuscating things.
author_association: CONTRIBUTOR
user: login: ScarKy0
created_at: 2025-04-18T23:40:58Z
body: Random test fail strikes again.
author_association: CONTRIBUTOR
user: login: ScarKy0
created_at: 2025-04-18T23:44:25Z
body: Peak
author_association: CONTRIBUTOR
user: login: Vinkoln
created_at: 2025-04-19T01:59:43Z
body: > Подумав об этом подробнее, я почти уверен, что это будет грехом картирования для больших карт, где не совсем ясно, что они будут охватывать весь отдел. Также я думаю, что нахождение на грани и неудачная компоновка будут раздражающей инженерной проблемой.
> 
> например: (exo suit fabs внизу слева, sci fabs вверху справа) ![изображение](https://private-user-images.githubusercontent.com/107586145/433642295-07dc6c49-9952-40d8-9caa-23af57d5dd81.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NDUwMjY1NjgsIm5iZiI6MTc0NTAyNjI2OCwicGF0aCI6Ii8xMDc1ODYxNDUvNDMzNjQyMjk1LTA3ZGM2YzQ5LTk5NTItNDBkOC05Y2FhLTIzYWY1N2Q1ZGQ4MS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwNDE5JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDQxOVQwMTMxMDhaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1hMjdiNTRmNDA5Njc1NTk4YmNjNDY1N2M3MzU3NWFkNDNiNDdmNWRkYTY3ZTAxZjgyMWI0NjJhZDI2NzljNDUxJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.Kyf7lRB7iJU4EkxFyPM9W6Pu1zjOKGZD3yy0CQ1VBt0)
> 
> Я думаю, что нам было бы гораздо лучше с другой системой "труб" для поставки материалов. Я знаю, что группы узлов - это больно. <3

it is possible to implement the connection of the silo to the machines via a multi-tool, and the connected machines are serviced in the silo interface (they can be disconnected in the silo interface), such a supply will eliminate the possibility of stealing resources from machines using the silo
author_association: NONE