Repository: space-wizards/space-station-14
Pull request #37066: Decal Spawners
Author: login: southbridge-fur
Labels: name: P2: Raised
description: Priority: Item has a raised priority, indicating it might get increased maintainer attention.
name: T: New Feature
description: Type: New feature or content, or extending existing content
name: D3: Low
description: Difficulty: Some codebase knowledge required.
name: Changes: Sprites
description: Changes: Might require knowledge of spriting or visual design.
name: A: Mapping
description: Area: Maps, or tools used for mapping
name: S: Approved
description: Status: Reviewed and approved by at least one maintainer; a PR may require another approval.
name: size/M
description: Denotes a PR that changes 100-999 lines.
Created: 2025-04-30T06:58:59Z | Merged: 2025-05-27T23:55:48Z

<!-- Guidelines: https://docs.spacestation14.io/en/getting-started/pr-guideline -->

## About the PR

Added decal spawners! Spawners that spawn decals for mapping, round start variation, and whatever else you might want to use them for!

## Why / Balance
<!-- Discuss how this would affect game balance or explain why it was changed. Link any relevant discussions or issues. -->

Map makers are often frustrated with having to place dirt decals, since they can be tedious and clog up workflows. 
But not anymore! 
With decal spawners, dirt, plants, and whatever else we may think of can now be automated away!
These spawners operate like any other spawners, in that they can be placed in maps to spawn decals round-start with plenty of variation. 

I've also taken the liberty to implement some round-start variations involving some of these spawners, just so all maps can have some level of starting dirt. 

Also as a janitor main I like having something to do on maps where I actually have to clean things, and having random variations helps include more variety between rounds where some areas will be more dirty than others. 

## Technical details
<!-- Summary of code changes for easier review. -->

Added a new Component
- `RandomDecalSpawnerComponent`

With the following parameters

- `Decals` - A list of decals to select from randomly.
- `Radius` - The radius to use for spawning decals.
- `Prob` - The probability that a decal spawns when an attempt is made to spawn one.
- `MaxDecals` - The Maximum amount of decals to spawn.
- `MaxDecalsPerTile` - The maximum amount of decals to spawn per tile in the Radius.
- `RandomRotation` - Whether to give the decal a random rotation.
- `SnapRotation` - Whether to snap the rotation to 90 degrees.
- `SnapPosition` - Whether to snap the position to the center of a tile.
- `zIndex` - The zIndex for the decals.
- `Color` - The color for the decals.
- `RandomColorList` - A list of colors to select from, if this is set it will override the `Color` parameter.
- `Cleanable` - Whether the decals are cleanable or not.
- `TileWhitelist - A whitelist for tiles to only spawn decals on. Overrides the blacklist.
- `TileBlacklist` - A blacklist for tiles to avoid spawning on.
- `DeleteSpawnerAfterSpawn` - Whether to delete the entity after the decals are spawned.

## 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. -->

This is the current list of spawners I've added. Plenty more could be added but this is just the list I've made. They're fairly easy to make with plenty of options for folks who want them.
![image](https://github.com/user-attachments/assets/0951fc92-0075-437f-8a7a-dada20a13558)

Spawners can be given tile blacklists, which prevents them from spawning on particular tiles. 
![image](https://github.com/user-attachments/assets/3db8fda3-28ea-4d5b-965b-a304cac04373)

Some examples of round start variation
![image](https://github.com/user-attachments/assets/4f3265ff-a201-4944-bc34-a9a66ecd68d1)
![image](https://github.com/user-attachments/assets/a2a6d8d5-a741-4e22-8f13-e576c570fc2a)
![image](https://github.com/user-attachments/assets/c920d058-78c7-41ac-ac2f-ad0f5ce2f3a6)
![image](https://github.com/user-attachments/assets/1e4c8a37-86fc-4418-9e3b-87c22ffbd9ad)

For folks who aren't familiar with yaml, these are the options that get exposed to map makers in the spawners, allowing for a great deal of customization.
![image](https://github.com/user-attachments/assets/a1ffb664-b9fa-4cca-881a-29017b10ffcb)


## 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**
<!-- Add a Changelog entry to make players aware of new features or changes that could affect gameplay.
Make sure to read the guidelines and take this Changelog template out of the comment block in order for it to show up.
Changelog must have a :cl: symbol, so the bot recognizes the changes and adds them to the game's changelog. -->

:cl: Southbridge
MAPS:
- add: Added Decal Spawners



Diff:
diff --git a/Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs b/Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
new file mode 100644
index 0000000000000..50e9137cbd749
--- /dev/null
+++ b/Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
@@ -0,0 +1,122 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+using Content.Shared.Decals;
+
+namespace Content.Server.Spawners.Components;
+
+/// <summary>
+/// This component spawns decals around the entity on MapInit.
+/// See doc strings for the various parameters for more information.
+/// </summary>
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<ProtoId<DecalPrototype>> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in. 0 will target only the tile the entity is on.
+    /// </summary>
+    [DataField]
+    public float Radius = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn across the entire radius.
+    /// </summary>
+    [DataField]
+    public int MaxDecals = 1;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn within a tile.
+    /// </summary>
+    /// <remarks>
+    /// A value <= 0 or null is considered unlimited.
+    /// </remarks>
+    [DataField]
+    public int? MaxDecalsPerTile = null;
+
+    /// <summary>
+    /// Whether decals should have a random rotation applied to them.
+    /// </summary>
+    [DataField]
+    public bool RandomRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to 90 degree orientations, does nothing if RandomRotation is false.
+    /// </summary>
+    [DataField]
+    public bool SnapRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to the center omf a grid space or be placed randoly.
+    /// </summary>
+    /// <remarks>
+    /// A null value will cause this to attempt to use the default value (DefaultSnap) for the decal.
+    /// </remarks>
+    [DataField]
+    public bool? SnapPosition = false;
+
+    /// <summary>
+    /// zIndex for the generated decals
+    /// </summary>
+    [DataField]
+    public int ZIndex = 0;
+
+    /// <summary>
+    /// Color for the generated decals. Does nothing if RandomColorList is set.
+    /// </summary>
+    [DataField]
+    public Color Color = Color.White;
+
+    /// <summary>
+    /// A random color to select from. Overrides Color if set.
+    /// </summary>
+    [DataField]
+    public List<Color>? RandomColorList = new();
+
+    /// <summary>
+    /// Whether the new decals are cleanable or not
+    /// </summary>
+    /// <remarks>
+    /// A null value will cause this to attempt to use the default value (DefaultCleanable) for the decal.
+    /// </remarks>
+    [DataField]
+    public bool? Cleanable = null;
+
+    /// <summary>
+    /// A list of tile prototype IDs to only place decals on.
+    /// </summary>
+    /// <remarks>
+    /// Causes the TileBlacklist to be ignored if this is set.
+    /// Note that due to the nature of tile-based placement, it's possible for decals to "spill over" onto nearby tiles.
+    /// This is mostly so dirt decals don't go on diagonal tiles that won't work for them.
+    /// </remarks>
+    [DataField]
+    public List<ProtoId<ContentTileDefinition>> TileWhitelist = new();
+
+    /// <summary>
+    /// A list of tile prototype IDs to avoid placing decals on.
+    /// </summary>
+    /// <remarks>
+    /// Ignored if TileWhitelist is set.
+    /// Note that due to the nature of tile-based placement, it's possible for decals to "spill over" onto nearby tiles.
+    /// This is mostly so dirt decals don't go on diagonal tiles that won't work for them.
+    /// </remarks>
+    [DataField]
+    public List<ProtoId<ContentTileDefinition>> TileBlacklist = new();
+
+    /// <summary>
+    /// Sets whether to delete the entity with this component after the spawner is finished.
+    /// </summary>
+    [DataField]
+    public bool DeleteSpawnerAfterSpawn = false;
+}
diff --git a/Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs b/Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs
new file mode 100644
index 0000000000000..1182bc131ca8c
--- /dev/null
+++ b/Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs
@@ -0,0 +1,130 @@
+using System.Numerics;
+using Content.Server.Decals;
+using Content.Server.Spawners.Components;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.Spawners.EntitySystems;
+
+public sealed class RandomDecalSpawnerSystem : EntitySystem
+{
+    [Dependency] private readonly DecalSystem _decal = default!;
+    [Dependency] private readonly SharedMapSystem _map = default!;
+    [Dependency] private readonly IPrototypeManager _prototypes = default!;
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<RandomDecalSpawnerComponent, MapInitEvent>(OnMapInit);
+    }
+
+    private void OnMapInit(EntityUid uid, RandomDecalSpawnerComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public bool TrySpawn(Entity<RandomDecalSpawnerComponent?> ent)
+    {
+        if (!TryComp<RandomDecalSpawnerComponent>(ent, out var comp))
+            return false;
+
+        if (comp.Decals.Count == 0)
+            return false;
+
+        var tileWhitelist = new List<ITileDefinition>();
+        if (comp.TileWhitelist.Count > 0)
+        {
+            foreach (var tileProto in comp.TileWhitelist)
+            {
+                if (_tileDefs.TryGetDefinition(tileProto, out var tileDef))
+                    tileWhitelist.Add(tileDef);
+            }
+        }
+        else if (comp.TileBlacklist.Count > 0)
+        {
+            foreach (var tileDef in _tileDefs)
+            {
+                if (!comp.TileBlacklist.Contains(tileDef.ID))
+                    tileWhitelist.Add(tileDef);
+            }
+        }
+
+        var xform = Transform(ent);
+        if (!TryComp<MapGridComponent>(xform.GridUid, out var grid))
+            return false;
+
+        var addedDecals = new Dictionary<string, int>();
+
+        for (var i = 0; i < comp.MaxDecals; i++)
+        {
+            if (comp.Prob < 1f && _random.NextFloat() > comp.Prob)
+                continue;
+
+            // The vector added here is just to center the generated decals to the tile the spawner is on.
+            var localPos = xform.Coordinates.Position + _random.NextVector2(comp.Radius) + new Vector2(-0.5f, -0.5f);
+            var position = new EntityCoordinates(xform.GridUid.Value, localPos);
+
+            var tileRef = _map.GetTileRef(xform.GridUid.Value, grid, position);
+
+            if (tileWhitelist.Count > 0)
+            {
+                _tileDefs.TryGetDefinition(tileRef.Tile.TypeId, out var currTileDef);
+                if (currTileDef is null || !tileWhitelist.Contains(currTileDef))
+                    continue;
+            }
+
+            var tileRefStr = tileRef.ToString();
+            if (comp.MaxDecalsPerTile is > 0)
+            {
+                addedDecals.TryAdd(tileRefStr, 0);
+                if (addedDecals[tileRefStr] >= comp.MaxDecalsPerTile)
+                    continue;
+            }
+
+            var decalProtoId = _random.Pick(comp.Decals);
+            var decalProto = _prototypes.Index(decalProtoId);
+            var snapPosition = comp.SnapPosition ?? decalProto.DefaultSnap;
+            if (snapPosition)
+            {
+                position = position.WithPosition(tileRef.GridIndices * grid.TileSize);
+            }
+
+            var cleanable = comp.Cleanable ?? decalProto.DefaultCleanable;
+
+            var rotation = Angle.Zero;
+            if (comp.RandomRotation)
+            {
+                if (comp.SnapRotation)
+                    rotation = new Angle((MathF.PI / 2f) * _random.Next(3));
+                else
+                    rotation = _random.NextAngle();
+            }
+
+            var color = comp.Color;
+            if (comp.RandomColorList != null && comp.RandomColorList.Count != 0)
+                color = _random.Pick(comp.RandomColorList);
+
+            _decal.TryAddDecal(
+                decalProtoId,
+                position,
+                out _,
+                color,
+                rotation,
+                comp.ZIndex,
+                cleanable
+            );
+
+            if (comp.MaxDecalsPerTile is > 0)
+                addedDecals[tileRefStr]++;
+        }
+
+        return true;
+    }
+}
diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/base.yml b/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/base.yml
new file mode 100644
index 0000000000000..3c5fc976789d9
--- /dev/null
+++ b/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/base.yml
@@ -0,0 +1,10 @@
+- type: entity
+  parent: MarkerBase
+  id: DecalSpawnerBase
+  name: Decal Spawner
+  abstract: true
+  components:
+  - type: Sprite
+    layers:
+    - state: green
+    - state: decal
diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/dirt.yml b/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/dirt.yml
new file mode 100644
index 0000000000000..3286abe78e812
--- /dev/null
+++ b/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/dirt.yml
@@ -0,0 +1,157 @@
+- type: entity
+  parent: DecalSpawnerBase
+  id: DecalSpawnerDirtBase
+  abstract: true
+  components:
+  - type: RandomDecalSpawner
+    decals:
+    - DirtLight
+    - DirtMedium
+    - DirtHeavy
+    maxDecalsPerTile: 1
+    snapPosition: true
+    zIndex: 1
+    prob: 0.8
+    color: '#FFFFFF7F'
+    cleanable: true
+    tileBlacklist: # Everything here just looks bad if it gets regular dirt on it
+    - FloorSteelMono
+    - FloorSteelDiagonal
+    - FloorSteelOffset
+    - FloorSteelDiagonalMini
+    - FloorWood
+    - FloorWoodLarge
+    - FloorWhiteMono
+    - FloorWhiteDiagonal
+    - FloorWhiteOffset
+    - FloorWhiteDiagonalMini
+    - FloorDarkMono
+    - FloorDarkDiagonal
+    - FloorDarkOffset
+    - FloorDarkDiagonalMini
+    - FloorArcadeBlue
+    - FloorArcadeBlue2
+    - FloorArcadeRed
+    - FloorEighties
+    - FloorCarpetClown
+    - FloorCarpetOffice
+    - FloorBoxing
+    - FloorGym
+    - FloorGlass
+    - FloorRGlass
+    - FloorAstroGrass
+    - FloorMowedAstroGrass
+    - FloorJungleAstroGrass
+    - FloorAstroIce
+    - FloorAstroSnow
+    - FloorAstroAsteroidSand
+    - FloorFlesh
+    - FloorAsteroidSandUnvariantized
+    - FloorAsteroidIronsandUnvariantized
+    - FloorCave
+    - FloorAsteroidIronsand
+    - FloorAsteroidTile
+    - FloorAsteroidSandDug
+    - FloorAsteroidSand
+    - FloorDirt
+    - FloorGrassLight
+    - FloorGrassDark
+    - FloorGrassJungle
+    - FloorGrass
+    - FloorAsphalt
+    - FloorReinforced
+    - FloorLino
+    deleteSpawnerAfterSpawn: true
+
+- type: entity
+  parent: DecalSpawnerDirtBase
+  id: DecalSpawnerDirtSingle
+  suffix: Dirt, 0 Radius
+  components:
+  - type: RandomDecalSpawner
+    radius: 0
+    prob: 1.0
+    maxDecals: 1
+
+- type: entity
+  parent: DecalSpawnerDirtBase
+  id: DecalSpawnerDirtNear
+  suffix: Dirt, 1.5 Radius
+  components:
+  - type: RandomDecalSpawner
+    radius: 1.5
+    maxDecals: 5
+
+- type: entity
+  parent: DecalSpawnerDirtBase
+  id: DecalSpawnerDirtWide
+  suffix: Dirt, 3 Radius
+  components:
+  - type: RandomDecalSpawner
+    radius: 3
+    maxDecals: 20
+    maxDecalsPerTile: 2
+
+- type: entity
+  parent: DecalSpawnerDirtBase
+  id: DecalSpawnerDirtMonospace
+  suffix: Dirt, Monospace, 1 Radius
+  components:
+  - type: RandomDecalSpawner
+    radius: 1
+    maxDecals: 5
+    decals:
+    - Dirt
+    - DirtHeavyMonotile
+    tileBlacklist:
+    - FloorSteelDiagonal
+    - FloorSteelOffset
+    - FloorSteelDiagonalMini
+    - FloorWhiteDiagonal
+    - FloorWhiteOffset
+    - FloorWhiteDiagonalMini
+    - FloorDarkDiagonal
+    - FloorDarkOffset
+    - FloorDarkDiagonalMini
+    - FloorArcadeBlue
+    - FloorArcadeBlue2
+    - FloorArcadeRed
+    - FloorEighties
+    - FloorCarpetClown
+    - FloorCarpetOffice
+    - FloorBoxing
+    - FloorGym
+    - FloorAstroGrass
+    - FloorMowedAstroGrass
+    - FloorJungleAstroGrass
+    - FloorAstroIce
+    - FloorAstroSnow
+    - FloorAstroAsteroidSand
+    - FloorFlesh
+    - FloorAsteroidSandUnvariantized
+    - FloorAsteroidIronsandUnvariantized
+    - FloorCave
+    - FloorAsteroidIronsand
+    - FloorAsteroidTile
+    - FloorAsteroidSandDug
+    - FloorAsteroidSand
+    - FloorDirt
+    - FloorGrassLight
+    - FloorGrassDark
+    - FloorGrassJungle
+    - FloorGrass
+    - FloorAsphalt
+    - FloorReinforced
+
+- type: entity
+  parent: DecalSpawnerDirtMonospace
+  id: DecalSpawnerBurns
+  suffix: Burns, 1 Radius
+  components:
+  - type: RandomDecalSpawner
+    decals:
+    - burnt1
+    - burnt2
+    - burnt3
+    - burnt4
+    radius: 1
diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/flora.yml b/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/flora.yml
new file mode 100644
index 0000000000000..5164ca7aeaed4
--- /dev/null
+++ b/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/flora.yml
@@ -0,0 +1,183 @@
+- type: entity
+  parent: DecalSpawnerBase
+  id: DecalSpawnerFloraBase
+  abstract: true
+  components:
+  - type: RandomDecalSpawner
+    radius: 0.3
+    zIndex: 1
+    deleteSpawnerAfterSpawn: true
+    tileWhitelist:
+    - FloorAstroGrass
+    - FloorMowedAstroGrass
+    - FloorJungleAstroGrass
+    - FloorAstroIce
+    - FloorAstroSnow
+    - FloorAstroAsteroidSand
+    - FloorAsteroidSandUnvariantized
+    - FloorAsteroidIronsandUnvariantized
+    - FloorCave
+    - FloorAsteroidIronsand
+    - FloorAsteroidTile
+    - FloorAsteroidSandDug
+    - FloorAsteroidSand
+    - FloorDirt
+    - FloorGrassLight
+    - FloorGrassDark
+    - FloorGrassJungle
+    - FloorGrass
+
+- type: entity
+  parent: DecalSpawnerFloraBase
+  id: DecalSpawnerGrassyRock
+  suffix: Grassy Rock
+  components:
+  - type: Sprite
+    layers:
+    - state: green
+    - state: decal
+    - sprite: Decals/Flora/flora_rocks.rsi
+      state: rock01
+  - type: RandomDecalSpawner
+    decals:
+    - Rock01
+    - Rock02
+    - Rock03
+    - Rock04
+    - Rock05
+    - Rock06
+    - Rock07
+
+- type: entity
+  parent: DecalSpawnerFloraBase
+  id: DecalSpawnerBasaltRock
+  suffix: Basalt Rock
+  components:
+  - type: Sprite
+    layers:
+    - state: green
+    - state: decal
+    - sprite: Decals/basalt.rsi
+      state: basalt1
+  - type: RandomDecalSpawner
+    decals:
+    - Basalt1
+    - Basalt2
+    - Basalt3
+    - Basalt4
+    - Basalt5
+    - Basalt6
+    - Basalt7
+    - Basalt8
+    - Basalt9
+
+- type: entity
+  parent: DecalSpawnerFloraBase
+  id: DecalSpawnerBushesAC
+  suffix: Bushes (a-c)
+  components:
+  - type: Sprite
+    layers:
+    - state: green
+    - state: decal
+    - sprite: Decals/Flora/flora_bushes.rsi
+      state: busha1
+  - type: RandomDecalSpawner
+    decals:
+    - Busha1
+    - Busha2
+    - Busha3
+    - Bushb1
+    - Bushb2
+    - Bushb3
+    - Bushc1
+    - Bushc2
+    - Bushc3
+
+# I really don't want to add all the rest of the bushes right now, leaving this as an exercise for someone else.
+
+- type: entity
+  parent: DecalSpawnerFloraBase
+  id: DecalSpawnerFlowers
+  suffix: Flowers
+  components:
+  - type: Sprite
+    layers:
+    - state: green
+    - state: decal
+    - sprite: Decals/Flora/flora_flowers.rsi
+      state: flowersbr1
+  - type: RandomDecalSpawner
+    decals:
+    - Flowersbr1
+    - Flowersbr2
+    - Flowersbr3
+    - Flowerspv1
+    - Flowerspv2
+    - Flowerspv3
+    - Flowersy1
+    - Flowersy2
+    - Flowersy3
+    - Flowersy4
+
+- type: entity
+  parent: DecalSpawnerFloraBase
+  id: DecalSpawnerGrassAB
+  suffix: Grass (a-b)
+  components:
+  - type: Sprite
+    layers:
+    - state: green
+    - state: decal
+    - sprite: Decals/Flora/flora_grass.rsi
+      state: grassa1
+  - type: RandomDecalSpawner
+    decals:
+    - Grassa1
+    - Grassa2
+    - Grassa3
+    - Grassa4
+    - Grassa5
+    - Grassb1
+    - Grassb2
+    - Grassb3
+    - Grassb4
+    - Grassb5
+
+- type: entity
+  parent: DecalSpawnerFloraBase
+  id: DecalSpawnerGrassC
+  suffix: Grass (c)
+  components:
+  - type: Sprite
+    layers:
+    - state: green
+    - state: decal
+    - sprite: Decals/Flora/flora_grass.rsi
+      state: grassc1
+  - type: RandomDecalSpawner
+    decals:
+    - Grassc1
+    - Grassc2
+    - Grassc3
+    - Grassc4
+
+- type: entity
+  parent: DecalSpawnerFloraBase
+  id: DecalSpawnerGrassDE
+  suffix: Grass (d-e)
+  components:
+  - type: Sprite
+    layers:
+    - state: green
+    - state: decal
+    - sprite: Decals/Flora/flora_grass.rsi
+      state: grassd1
+  - type: RandomDecalSpawner
+    decals:
+    - Grassd1
+    - Grassd2
+    - Grassd3
+    - Grasse1
+    - Grasse2
+    - Grasse3
diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/splatters.yml b/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/splatters.yml
new file mode 100644
index 0000000000000..f498616fe8264
--- /dev/null
+++ b/Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/splatters.yml
@@ -0,0 +1,102 @@
+- type: entity
+  parent: DecalSpawnerBase
+  id: DecalSpawnerBloodSplatters
+  suffix: Blood Splatters, Footprints
+  components:
+  - type: RandomDecalSpawner
+    decals:
+    - footprint
+    - splatter
+    radius: 1
+    randomRotation: true
+    maxDecals: 3
+    prob: 0.5
+    zIndex: 1
+    color: '#9900007F'
+    cleanable: true
+    deleteSpawnerAfterSpawn: true
+
+- type: entity
+  parent: DecalSpawnerBase
+  id: DecalSpawnerGraffiti
+  suffix: Graffiti
+  components:
+  - type: RandomDecalSpawner
+    decals:
+    - Blasto
+    - Clandestine
+    - Cyber
+    - Diablo
+    - Donk
+    - Gene
+    - Gib
+    - Max
+    - Newton
+    - North
+    - Omni
+    - Osiron
+    - Prima
+    - Psyke
+    - Sirius
+    - Tunnel
+    - Waffle
+    - amyjon
+    - beepsky
+    - biohazard
+    - blueprint
+    - bottle
+    - carp
+    - cat
+    - clown
+    - corgi
+    - credit
+    - cyka
+    - danger
+    - disk
+    - dwarf
+    - end
+    - engie
+    - face
+    - fireaxe
+    - firedanger
+    - ghost
+    - guy
+    - heart
+    - like
+    - matt
+    - peace
+    - prolizard
+    - radiation
+    - revolution
+    - safe
+    - scroll
+    - shotgun
+    - skull
+    - snake
+    - star
+    - stickman
+    - taser
+    - toilet
+    - toolbox
+    - uboa
+    radius: 0.5
+    randomRotation: true
+    maxDecals: 1
+    zIndex: 2
+    randomColorList:
+    - aqua
+    - betterviolet
+    - blue
+    - chartreuse
+    - cyan
+    - deeppink
+    - fuchsia
+    - green
+    - indigo
+    - lime
+    - pink
+    - red
+    - silver
+    - yellow
+    cleanable: true
+    deleteSpawnerAfterSpawn: true
diff --git a/Resources/Prototypes/GameRules/roundstart.yml b/Resources/Prototypes/GameRules/roundstart.yml
index f5730ab00cdae..6056ac98d6117 100644
--- a/Resources/Prototypes/GameRules/roundstart.yml
+++ b/Resources/Prototypes/GameRules/roundstart.yml
@@ -449,3 +449,11 @@
       prob: 0.90
     - id: SolarPanelDamageVariationPass
     - id: SolarPanelEmptyVariationPass
+    - id: BasicDecalDirtVariationPass
+    - id: BasicDecalGraffitiVariationPass
+    - id: BasicDecalBrunsVariationPass
+      prob: 0.50
+      orGroup: monospaceDecals
+    - id: BasicDecalDirtMonospaceVariationPass
+      prob: 0.50
+      orGroup: monospaceDecals
diff --git a/Resources/Prototypes/GameRules/variation.yml b/Resources/Prototypes/GameRules/variation.yml
index d080965697a00..de7a0d7fab7c2 100644
--- a/Resources/Prototypes/GameRules/variation.yml
+++ b/Resources/Prototypes/GameRules/variation.yml
@@ -47,6 +47,46 @@
     entities:
     - id: RandomSpawner
 
+- type: entity
+  id: BasicDecalDirtVariationPass
+  parent: BaseVariationPass
+  components:
+  - type: EntitySpawnVariationPass
+    tilesPerEntityAverage: 80
+    tilesPerEntityStdDev: 5
+    entities:
+    - id: DecalSpawnerDirtWide
+
+- type: entity
+  id: BasicDecalGraffitiVariationPass
+  parent: BaseVariationPass
+  components:
+  - type: EntitySpawnVariationPass
+    tilesPerEntityAverage: 120
+    tilesPerEntityStdDev: 5
+    entities:
+    - id: DecalSpawnerGraffiti
+
+- type: entity
+  id: BasicDecalBrunsVariationPass
+  parent: BaseVariationPass
+  components:
+  - type: EntitySpawnVariationPass
+    tilesPerEntityAverage: 120
+    tilesPerEntityStdDev: 10
+    entities:
+    - id: DecalSpawnerBurns
+
+- type: entity
+  id: BasicDecalDirtMonospaceVariationPass
+  parent: BaseVariationPass
+  components:
+  - type: EntitySpawnVariationPass
+    tilesPerEntityAverage: 80
+    tilesPerEntityStdDev: 10
+    entities:
+    - id: DecalSpawnerDirtMonospace
+
 - type: weightedRandomFillSolution
   id: RandomFillTrashPuddle
   fills:
diff --git a/Resources/Textures/Markers/cross.rsi/decal.png b/Resources/Textures/Markers/cross.rsi/decal.png
new file mode 100644
index 0000000000000..0db5120e58a5b
Binary files /dev/null and b/Resources/Textures/Markers/cross.rsi/decal.png differ
diff --git a/Resources/Textures/Markers/cross.rsi/meta.json b/Resources/Textures/Markers/cross.rsi/meta.json
index 8ead41b4fbcf0..9c46093c755e7 100644
--- a/Resources/Textures/Markers/cross.rsi/meta.json
+++ b/Resources/Textures/Markers/cross.rsi/meta.json
@@ -1,7 +1,7 @@
 {
   "version": 1,
   "license": "CC-BY-SA-3.0",
-  "copyright": "Taken from https://github.com/vgstation-coders/vgstation13/blob/e71d6c4fba5a51f99b81c295dcaec4fc2f58fb19/icons/mob/screen1.dmi - modified to add 'timed'",
+  "copyright": "Taken from https://github.com/vgstation-coders/vgstation13/blob/e71d6c4fba5a51f99b81c295dcaec4fc2f58fb19/icons/mob/screen1.dmi - modified to add 'timed'. 'decal' by Southbridge.",
   "size": {
     "x": 32,
     "y": 32
@@ -24,6 +24,9 @@
     },
     {
       "name": "timed"
+    },
+    {
+      "name": "decal"
     }
   ]
 }


Reviews:
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T12:26:46Z
user: login: murphyneko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T17:32:30Z
user: login: murphyneko
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T17:35:03Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T18:26:06Z
user: login: slarticodefast
body: I did not look at everything, so not a full review, just a few quick comments
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T01:55:32Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T06:09:45Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T06:43:46Z
user: login: slarticodefast
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-03T12:55:16Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T19:47:58Z
user: login: EmoGarbage404
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T00:56:16Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T03:37:20Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T03:37:55Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T03:39:20Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T03:44:10Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T03:52:26Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T03:54:43Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T03:16:10Z
user: login: southbridge-fur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T03:21:25Z
user: login: EmoGarbage404
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T15:17:42Z
user: login: ArtisticRoomba
body: oh my good god its beautiful
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-27T23:33:30Z

Review comments:
diff_hunk: @@ -0,0 +1,153 @@
+- type: entity
+  parent: DecalSpawnerBase
+  id: DecalSpawnerDirtBase
+  abstract: true
+  components:
+  - type: RandomDecalSpawnerDistributed
+    decals:
+    - DirtLight
+    - DirtyMedium
path: Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/dirt.yml
user: login: southbridge-fur
body: I gotta fix this 
created_at: 2025-04-30T12:26:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.Components;
+
+public abstract partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<String> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in.
+    /// </summary>
+    [DataField]
+    public float Range = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// Whether decals should have a random rotation applied to them.
+    /// </summary>
+    [DataField]
+    public bool RandomRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to 90 degree orientations, does nothing if RandomRotation is false.
+    /// </summary>
+    [DataField]
+    public bool SnapRotation = false;
+
+    /// <summary>
+    /// zIndex for the generated decals
+    /// </summary>
+    [DataField]
+    public int zIndex = 0;
+
+    /// <summary>
+    /// Color for the generated decals
+    /// </summary>
+    [DataField]
+    public Color Color = Color.White;
+
+    /// <summary>
+    /// A random color to select from. Overrides Color if set.
+    /// </summary>
+    [DataField]
+    public List<Color> RandomColorList = new();
+
+    /// <summary>
+    /// Whether the new decals are cleanable or not
+    /// </summary>
+    [DataField]
+    public bool Cleanable = false;
+
+    /// <summary>
+    /// A list of tile names to avoid placing decals on.
+    /// </summary>
+    /// <remarks>
+    /// Note that due to the nature of tile-based placement, it's possible for decals to "spill over" onto nearby tiles.
+    /// This is mostly so dirt decals don't go on diagonal tiles that won't work for them.
+    /// </remarks>
+    [DataField]
+    public List<String> TileBlacklist = new();
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: murphyneko
body: Raw strings aren't supposed to be used here, I would probably suggest using List<ProtoId<ContentTileDefinition>> or List<ProtoId<ITileDefinition>> instead
created_at: 2025-04-30T17:30:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.Components;
+
+public abstract partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<String> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in.
+    /// </summary>
+    [DataField]
+    public float Range = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// Whether decals should have a random rotation applied to them.
+    /// </summary>
+    [DataField]
+    public bool RandomRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to 90 degree orientations, does nothing if RandomRotation is false.
+    /// </summary>
+    [DataField]
+    public bool SnapRotation = false;
+
+    /// <summary>
+    /// zIndex for the generated decals
+    /// </summary>
+    [DataField]
+    public int zIndex = 0;
+
+    /// <summary>
+    /// Color for the generated decals
+    /// </summary>
+    [DataField]
+    public Color Color = Color.White;
+
+    /// <summary>
+    /// A random color to select from. Overrides Color if set.
+    /// </summary>
+    [DataField]
+    public List<Color> RandomColorList = new();
+
+    /// <summary>
+    /// Whether the new decals are cleanable or not
+    /// </summary>
+    [DataField]
+    public bool Cleanable = false;
+
+    /// <summary>
+    /// A list of tile names to avoid placing decals on.
+    /// </summary>
+    /// <remarks>
+    /// Note that due to the nature of tile-based placement, it's possible for decals to "spill over" onto nearby tiles.
+    /// This is mostly so dirt decals don't go on diagonal tiles that won't work for them.
+    /// </remarks>
+    [DataField]
+    public List<String> TileBlacklist = new();
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: murphyneko
body: ![image](https://github.com/user-attachments/assets/805e690a-8842-4455-b022-7d5a23871fc0)
github ate my comment. i want a refund immediately
created_at: 2025-04-30T17:35:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.Components;
+
+public abstract partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<String> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in.
+    /// </summary>
+    [DataField]
+    public float Range = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// Whether decals should have a random rotation applied to them.
+    /// </summary>
+    [DataField]
+    public bool RandomRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to 90 degree orientations, does nothing if RandomRotation is false.
+    /// </summary>
+    [DataField]
+    public bool SnapRotation = false;
+
+    /// <summary>
+    /// zIndex for the generated decals
+    /// </summary>
+    [DataField]
+    public int zIndex = 0;
+
+    /// <summary>
+    /// Color for the generated decals
+    /// </summary>
+    [DataField]
+    public Color Color = Color.White;
+
+    /// <summary>
+    /// A random color to select from. Overrides Color if set.
+    /// </summary>
+    [DataField]
+    public List<Color> RandomColorList = new();
+
+    /// <summary>
+    /// Whether the new decals are cleanable or not
+    /// </summary>
+    [DataField]
+    public bool Cleanable = false;
+
+    /// <summary>
+    /// A list of tile names to avoid placing decals on.
+    /// </summary>
+    /// <remarks>
+    /// Note that due to the nature of tile-based placement, it's possible for decals to "spill over" onto nearby tiles.
+    /// This is mostly so dirt decals don't go on diagonal tiles that won't work for them.
+    /// </remarks>
+    [DataField]
+    public List<String> TileBlacklist = new();
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: southbridge-fur
body: done
created_at: 2025-04-30T18:26:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,77 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+
+namespace Content.Server.Spawners.Components;
+
+public abstract partial class RandomDecalSpawnerComponent : Component
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: slarticodefast
body: add documentation to the componentes themselves
created_at: 2025-05-02T01:37:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,77 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+
+namespace Content.Server.Spawners.Components;
+
+public abstract partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<String> Decals = new();
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: slarticodefast
body: use proto ids instead of strings
created_at: 2025-05-02T01:38:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,19 @@
+﻿using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.Components;
+
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerDistributedComponent : RandomDecalSpawnerComponent
path: Content.Server/Spawners/Components/RandomDecalSpawnerDistributedComponent.cs
user: login: slarticodefast
body: the inheritance here is sus and probably not needed
just combine the two spawners
created_at: 2025-05-02T01:44:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,178 @@
+using System.Numerics;
+using Content.Server.Spawners.Components;
+using Content.Server.Decals;
+using Content.Shared.Maps;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Random;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.EntitySystems;
+
+public sealed class RandomDecalSpawnerSystem : EntitySystem
+{
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly SharedMapSystem _map = default!;
+    [Dependency] private readonly DecalSystem _decal = default!;
+    [Dependency] private readonly IEntityManager _entities = default!;
+    [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<RandomDecalSpawnerDistributedComponent, MapInitEvent>(OnMapInit);
+        SubscribeLocalEvent<RandomDecalSpawnerScatteredComponent, MapInitEvent>(OnMapInit);
+    }
+
+    public void OnMapInit(EntityUid uid, RandomDecalSpawnerDistributedComponent component, MapInitEvent args)
path: Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs
user: login: slarticodefast
body: use `Entity<Component>` pattern
same for all the others
and subscription methods should be private
created_at: 2025-05-02T01:45:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,178 @@
+using System.Numerics;
+using Content.Server.Spawners.Components;
+using Content.Server.Decals;
+using Content.Shared.Maps;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Random;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.EntitySystems;
+
+public sealed class RandomDecalSpawnerSystem : EntitySystem
+{
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly SharedMapSystem _map = default!;
+    [Dependency] private readonly DecalSystem _decal = default!;
+    [Dependency] private readonly IEntityManager _entities = default!;
+    [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<RandomDecalSpawnerDistributedComponent, MapInitEvent>(OnMapInit);
+        SubscribeLocalEvent<RandomDecalSpawnerScatteredComponent, MapInitEvent>(OnMapInit);
+    }
+
+    public void OnMapInit(EntityUid uid, RandomDecalSpawnerDistributedComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid, component);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public void OnMapInit(EntityUid uid, RandomDecalSpawnerScatteredComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid, component);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public bool TrySpawn(EntityUid uid, RandomDecalSpawnerDistributedComponent component)
+    {
+        if (!TryComp<RandomDecalSpawnerDistributedComponent>(uid, out var comp))
+            return false;
+
+        if (component == null)
+            return false;
path: Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs
user: login: slarticodefast
body: just use `Resolve` and make the parameter nullable
`comp` and `component` are identical
and the null check is not needed because TryComp already returns false if it was
created_at: 2025-05-02T01:49:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,178 @@
+using System.Numerics;
+using Content.Server.Spawners.Components;
+using Content.Server.Decals;
+using Content.Shared.Maps;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Random;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.EntitySystems;
+
+public sealed class RandomDecalSpawnerSystem : EntitySystem
+{
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly SharedMapSystem _map = default!;
+    [Dependency] private readonly DecalSystem _decal = default!;
+    [Dependency] private readonly IEntityManager _entities = default!;
+    [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<RandomDecalSpawnerDistributedComponent, MapInitEvent>(OnMapInit);
+        SubscribeLocalEvent<RandomDecalSpawnerScatteredComponent, MapInitEvent>(OnMapInit);
+    }
+
+    public void OnMapInit(EntityUid uid, RandomDecalSpawnerDistributedComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid, component);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public void OnMapInit(EntityUid uid, RandomDecalSpawnerScatteredComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid, component);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public bool TrySpawn(EntityUid uid, RandomDecalSpawnerDistributedComponent component)
+    {
+        if (!TryComp<RandomDecalSpawnerDistributedComponent>(uid, out var comp))
+            return false;
+
+        if (component == null)
+            return false;
+
+        if (component.Decals.Count == 0)
+            return false;
+
+        var tileBlacklist = new List<ITileDefinition>();
+        if (component.TileBlacklist.Count > 0)
+        {
+            tileBlacklist = GetTileDefs(component.TileBlacklist);
+        }
+
+        // I feel like there's a better way to do this, but I can't find it
path: Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs
user: login: slarticodefast
body: yeah, this looks sus
created_at: 2025-05-02T01:52:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,178 @@
+using System.Numerics;
+using Content.Server.Spawners.Components;
+using Content.Server.Decals;
+using Content.Shared.Maps;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Random;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.EntitySystems;
+
+public sealed class RandomDecalSpawnerSystem : EntitySystem
+{
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly SharedMapSystem _map = default!;
+    [Dependency] private readonly DecalSystem _decal = default!;
+    [Dependency] private readonly IEntityManager _entities = default!;
+    [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<RandomDecalSpawnerDistributedComponent, MapInitEvent>(OnMapInit);
+        SubscribeLocalEvent<RandomDecalSpawnerScatteredComponent, MapInitEvent>(OnMapInit);
+    }
+
+    public void OnMapInit(EntityUid uid, RandomDecalSpawnerDistributedComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid, component);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public void OnMapInit(EntityUid uid, RandomDecalSpawnerScatteredComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid, component);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public bool TrySpawn(EntityUid uid, RandomDecalSpawnerDistributedComponent component)
+    {
+        if (!TryComp<RandomDecalSpawnerDistributedComponent>(uid, out var comp))
+            return false;
+
+        if (component == null)
+            return false;
+
+        if (component.Decals.Count == 0)
+            return false;
+
+        var tileBlacklist = new List<ITileDefinition>();
+        if (component.TileBlacklist.Count > 0)
+        {
+            tileBlacklist = GetTileDefs(component.TileBlacklist);
+        }
+
+        // I feel like there's a better way to do this, but I can't find it
+        var xform = Transform(uid);
+        if (!TryComp<MapGridComponent>(xform.GridUid, out var grid))
+            return false;
+
+        var localPos = xform.Coordinates.Position;
+
+        var tileRefs = _map.GetLocalTilesIntersecting(
+            uid,
+            grid,
+            new Box2(localPos + new Vector2(-component.Range, -component.Range),
+                localPos + new Vector2(component.Range, component.Range))
+        );
+
+        foreach (var tileRef in tileRefs)
+        {
+            var basePosition = new EntityCoordinates(xform.GridUid.Value, tileRef.GridIndices * grid.TileSize);
+            if (!basePosition.TryDistance(_entities, new EntityCoordinates(uid, 0f, 0f), out var distance))
+                continue;
+
+            for (var i = 0; i < component.MaxDecalsPerTile; i++)
+            {
+                var position = basePosition;
+
+                if (!component.SnapPosition)
+                    // we create this new vector instead of using _random.NextVector because using
+                    // _random.NextVector could move the decal out of the tile.
+                    position = position.Offset(new Vector2(_random.NextFloat(), _random.NextFloat()));
+
+                SpawnDecal(uid, component, xform.GridUid.Value, grid, position, tileBlacklist);
+            }
+        }
+
+        return true;
+    }
+
+    public bool TrySpawn(EntityUid uid, RandomDecalSpawnerScatteredComponent component)
+    {
+        if (!TryComp<RandomDecalSpawnerScatteredComponent>(uid, out var comp))
+            return false;
+
+        if (component == null)
+            return false;
path: Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs
user: login: slarticodefast
body: same as above
created_at: 2025-05-02T01:54:31Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,19 @@
+﻿using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.Components;
+
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerDistributedComponent : RandomDecalSpawnerComponent
path: Content.Server/Spawners/Components/RandomDecalSpawnerDistributedComponent.cs
user: login: southbridge-fur
body: alright

created_at: 2025-05-03T06:09:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,77 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+
+namespace Content.Server.Spawners.Components;
+
+public abstract partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<String> Decals = new();
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: southbridge-fur
body: no can do, the decal system expects strings https://github.com/space-wizards/space-station-14/blob/213a9ef36d5dc48e4304eadd11402000bf767210/Content.Server/Decals/DecalSystem.cs#L285
created_at: 2025-05-03T06:43:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,77 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+
+namespace Content.Server.Spawners.Components;
+
+public abstract partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<String> Decals = new();
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: slarticodefast
body: they will automatically cast back into a string
the benefit of a protoId is that it gets validated for existance by the linter
created_at: 2025-05-03T12:55:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,77 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+
+namespace Content.Server.Spawners.Components;
+
+public abstract partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<String> Decals = new();
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: southbridge-fur
body: alright done
created_at: 2025-05-03T19:47:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+using Content.Shared.Decals;
+
+namespace Content.Server.Spawners.Components;
+
+/// <summary>
+/// This component spawns decals around the entity on MapInit.
+/// See doc strings for the various parameters for more information.
+/// </summary>
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<ProtoId<DecalPrototype>> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in. 0 will target only the tile the entity is on.
+    /// </summary>
+    [DataField]
+    public float Radius = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn across the entire radius.
+    /// </summary>
+    [DataField]
+    public int MaxDecals = 1;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn within a tile.
+    /// </summary>
+    /// <remarks>
+    /// A value <= 0 is considered unlimited.
+    /// </remarks>
+    [DataField]
+    public int MaxDecalsPerTile = 0;
+
+    /// <summary>
+    /// Whether decals should have a random rotation applied to them.
+    /// </summary>
+    [DataField]
+    public bool RandomRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to 90 degree orientations, does nothing if RandomRotation is false.
+    /// </summary>
+    [DataField]
+    public bool SnapRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to the center omf a grid space or be placed randoly.
+    /// </summary>
+    [DataField]
+    public bool SnapPosition = false;
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: EmoGarbage404
body: you have a handful of these fields that set values on the decals, but i think they should include a nullable case for using the default decal values. That way, you don't need to manually specify that dirt snaps to grid, as the dirt decals have that behavior by default.
created_at: 2025-05-08T00:53:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+using Content.Shared.Decals;
+
+namespace Content.Server.Spawners.Components;
+
+/// <summary>
+/// This component spawns decals around the entity on MapInit.
+/// See doc strings for the various parameters for more information.
+/// </summary>
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<ProtoId<DecalPrototype>> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in. 0 will target only the tile the entity is on.
+    /// </summary>
+    [DataField]
+    public float Radius = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn across the entire radius.
+    /// </summary>
+    [DataField]
+    public int MaxDecals = 1;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn within a tile.
+    /// </summary>
+    /// <remarks>
+    /// A value <= 0 is considered unlimited.
+    /// </remarks>
+    [DataField]
+    public int MaxDecalsPerTile = 0;
+
+    /// <summary>
+    /// Whether decals should have a random rotation applied to them.
+    /// </summary>
+    [DataField]
+    public bool RandomRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to 90 degree orientations, does nothing if RandomRotation is false.
+    /// </summary>
+    [DataField]
+    public bool SnapRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to the center omf a grid space or be placed randoly.
+    /// </summary>
+    [DataField]
+    public bool SnapPosition = false;
+
+    /// <summary>
+    /// zIndex for the generated decals
+    /// </summary>
+    [DataField]
+    public int ZIndex = 0;
+
+    /// <summary>
+    /// Color for the generated decals. Does nothing if RandomColorList is set.
+    /// </summary>
+    [DataField]
+    public Color Color = Color.White;
+
+    /// <summary>
+    /// A random color to select from. Overrides Color if set.
+    /// </summary>
+    [DataField]
+    public List<Color> RandomColorList = new();
+
+    /// <summary>
+    /// Whether the new decals are cleanable or not
+    /// </summary>
+    [DataField]
+    public bool Cleanable = false;
+
+    /// <summary>
+    /// A list of tile prototype IDs to avoid placing decals on.
+    /// </summary>
+    /// <remarks>
+    /// Note that due to the nature of tile-based placement, it's possible for decals to "spill over" onto nearby tiles.
+    /// This is mostly so dirt decals don't go on diagonal tiles that won't work for them.
+    /// </remarks>
+    [DataField]
+    public List<ProtoId<ContentTileDefinition>> TileBlacklist = new();
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: EmoGarbage404
body: this definitely should also have a whitelist option
created_at: 2025-05-08T00:54:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+using Content.Shared.Decals;
+
+namespace Content.Server.Spawners.Components;
+
+/// <summary>
+/// This component spawns decals around the entity on MapInit.
+/// See doc strings for the various parameters for more information.
+/// </summary>
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<ProtoId<DecalPrototype>> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in. 0 will target only the tile the entity is on.
+    /// </summary>
+    [DataField]
+    public float Radius = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn across the entire radius.
+    /// </summary>
+    [DataField]
+    public int MaxDecals = 1;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn within a tile.
+    /// </summary>
+    /// <remarks>
+    /// A value <= 0 is considered unlimited.
+    /// </remarks>
+    [DataField]
+    public int MaxDecalsPerTile = 0;
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: EmoGarbage404
body: it'd be more standard to use null to indicate the base case rather than <= 0
created_at: 2025-05-08T00:54:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,128 @@
+using System.Numerics;
+using Content.Shared.Decals;
+using Content.Server.Spawners.Components;
+using Content.Server.Decals;
+using Content.Shared.Maps;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Random;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.EntitySystems;
+
+public sealed class RandomDecalSpawnerSystem : EntitySystem
+{
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly SharedMapSystem _map = default!;
+    [Dependency] private readonly DecalSystem _decal = default!;
+    [Dependency] private readonly IEntityManager _entities = default!;
+    [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<RandomDecalSpawnerComponent, MapInitEvent>(OnMapInit);
+    }
+
+    private void OnMapInit(EntityUid uid, RandomDecalSpawnerComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public bool TrySpawn(Entity<RandomDecalSpawnerComponent?> ent)
+    {
+        if (!TryComp<RandomDecalSpawnerComponent>(ent, out var comp))
+            return false;
+
+        if (comp.Decals.Count == 0)
+            return false;
+
+        var tileBlacklist = new List<ITileDefinition>();
+        if (comp.TileBlacklist.Count > 0)
+        {
+            tileBlacklist = GetTileDefs(comp.TileBlacklist);
+        }
+
+        var xform = Transform(ent);
+        if (!TryComp<MapGridComponent>(xform.GridUid, out var grid))
+            return false;
+
+        var addedDecals = new Dictionary<String, int>();
+
+        for (var i = 0; i < comp.MaxDecals; i++)
+        {
+            if (comp.Prob < 1f && _random.NextFloat() > comp.Prob)
+                continue;
+
+            // The vector added here is just to center the generated decals to the tile the spawner is on.
+            var localPos = xform.Coordinates.Position + _random.NextVector2(comp.Radius) + new Vector2(-0.5f, -0.5f);
+            var position = new EntityCoordinates(xform.GridUid.Value, localPos);
+
+            var tileRef = _map.GetTileRef(xform.GridUid.Value, grid, position);
+            var tileRefStr = tileRef.ToString();
path: Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs
user: login: EmoGarbage404
body: i dont think this checks if the tile you've gotten is just space, which would be an issue.
created_at: 2025-05-08T00:55:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+- type: entity
+  parent: DecalSpawnerBase
+  id: DecalSpawnerDirtBase
+  abstract: true
+  components:
+  - type: RandomDecalSpawner
+    decals:
+    - DirtLight
+    - DirtMedium
+    - DirtHeavy
+    maxDecalsPerTile: 1
+    snapPosition: true
+    zIndex: 1
+    prob: 0.8
+    color: '#FFFFFF7F'
+    cleanable: true
+    tileBlacklist: # Everything here just looks bad if it gets regular dirt on it
path: Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/dirt.yml
user: login: EmoGarbage404
body: see: my comment on a whitelist. we have a lot more non-square tiles than normal square ones.
created_at: 2025-05-08T00:56:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+using Content.Shared.Decals;
+
+namespace Content.Server.Spawners.Components;
+
+/// <summary>
+/// This component spawns decals around the entity on MapInit.
+/// See doc strings for the various parameters for more information.
+/// </summary>
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<ProtoId<DecalPrototype>> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in. 0 will target only the tile the entity is on.
+    /// </summary>
+    [DataField]
+    public float Radius = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn across the entire radius.
+    /// </summary>
+    [DataField]
+    public int MaxDecals = 1;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn within a tile.
+    /// </summary>
+    /// <remarks>
+    /// A value <= 0 is considered unlimited.
+    /// </remarks>
+    [DataField]
+    public int MaxDecalsPerTile = 0;
+
+    /// <summary>
+    /// Whether decals should have a random rotation applied to them.
+    /// </summary>
+    [DataField]
+    public bool RandomRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to 90 degree orientations, does nothing if RandomRotation is false.
+    /// </summary>
+    [DataField]
+    public bool SnapRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to the center omf a grid space or be placed randoly.
+    /// </summary>
+    [DataField]
+    public bool SnapPosition = false;
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: southbridge-fur
body: I can never trust the default values myself so I don't use them. Plus using default values would require someone using this to know what the default value of the tile does rather than simply setting it themselves in this component.
created_at: 2025-05-08T03:37:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+using Content.Shared.Decals;
+
+namespace Content.Server.Spawners.Components;
+
+/// <summary>
+/// This component spawns decals around the entity on MapInit.
+/// See doc strings for the various parameters for more information.
+/// </summary>
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<ProtoId<DecalPrototype>> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in. 0 will target only the tile the entity is on.
+    /// </summary>
+    [DataField]
+    public float Radius = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn across the entire radius.
+    /// </summary>
+    [DataField]
+    public int MaxDecals = 1;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn within a tile.
+    /// </summary>
+    /// <remarks>
+    /// A value <= 0 is considered unlimited.
+    /// </remarks>
+    [DataField]
+    public int MaxDecalsPerTile = 0;
+
+    /// <summary>
+    /// Whether decals should have a random rotation applied to them.
+    /// </summary>
+    [DataField]
+    public bool RandomRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to 90 degree orientations, does nothing if RandomRotation is false.
+    /// </summary>
+    [DataField]
+    public bool SnapRotation = false;
+
+    /// <summary>
+    /// Whether decals should snap to the center omf a grid space or be placed randoly.
+    /// </summary>
+    [DataField]
+    public bool SnapPosition = false;
+
+    /// <summary>
+    /// zIndex for the generated decals
+    /// </summary>
+    [DataField]
+    public int ZIndex = 0;
+
+    /// <summary>
+    /// Color for the generated decals. Does nothing if RandomColorList is set.
+    /// </summary>
+    [DataField]
+    public Color Color = Color.White;
+
+    /// <summary>
+    /// A random color to select from. Overrides Color if set.
+    /// </summary>
+    [DataField]
+    public List<Color> RandomColorList = new();
+
+    /// <summary>
+    /// Whether the new decals are cleanable or not
+    /// </summary>
+    [DataField]
+    public bool Cleanable = false;
+
+    /// <summary>
+    /// A list of tile prototype IDs to avoid placing decals on.
+    /// </summary>
+    /// <remarks>
+    /// Note that due to the nature of tile-based placement, it's possible for decals to "spill over" onto nearby tiles.
+    /// This is mostly so dirt decals don't go on diagonal tiles that won't work for them.
+    /// </remarks>
+    [DataField]
+    public List<ProtoId<ContentTileDefinition>> TileBlacklist = new();
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: southbridge-fur
body: Fair, I just thought I'd get dinged for having both so I didn't want to put in the effort of implementing both only to be told to remove one or the other. Like I already had to do where I smashed two different placement strategies into one.
created_at: 2025-05-08T03:37:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+using Content.Shared.Decals;
+
+namespace Content.Server.Spawners.Components;
+
+/// <summary>
+/// This component spawns decals around the entity on MapInit.
+/// See doc strings for the various parameters for more information.
+/// </summary>
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<ProtoId<DecalPrototype>> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in. 0 will target only the tile the entity is on.
+    /// </summary>
+    [DataField]
+    public float Radius = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn across the entire radius.
+    /// </summary>
+    [DataField]
+    public int MaxDecals = 1;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn within a tile.
+    /// </summary>
+    /// <remarks>
+    /// A value <= 0 is considered unlimited.
+    /// </remarks>
+    [DataField]
+    public int MaxDecalsPerTile = 0;
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: southbridge-fur
body: I mean I could allow null as well but in what case would anyone set this value to null if it always defaults to 0?
created_at: 2025-05-08T03:39:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,128 @@
+using System.Numerics;
+using Content.Shared.Decals;
+using Content.Server.Spawners.Components;
+using Content.Server.Decals;
+using Content.Shared.Maps;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Random;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.EntitySystems;
+
+public sealed class RandomDecalSpawnerSystem : EntitySystem
+{
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly SharedMapSystem _map = default!;
+    [Dependency] private readonly DecalSystem _decal = default!;
+    [Dependency] private readonly IEntityManager _entities = default!;
+    [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<RandomDecalSpawnerComponent, MapInitEvent>(OnMapInit);
+    }
+
+    private void OnMapInit(EntityUid uid, RandomDecalSpawnerComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public bool TrySpawn(Entity<RandomDecalSpawnerComponent?> ent)
+    {
+        if (!TryComp<RandomDecalSpawnerComponent>(ent, out var comp))
+            return false;
+
+        if (comp.Decals.Count == 0)
+            return false;
+
+        var tileBlacklist = new List<ITileDefinition>();
+        if (comp.TileBlacklist.Count > 0)
+        {
+            tileBlacklist = GetTileDefs(comp.TileBlacklist);
+        }
+
+        var xform = Transform(ent);
+        if (!TryComp<MapGridComponent>(xform.GridUid, out var grid))
+            return false;
+
+        var addedDecals = new Dictionary<String, int>();
+
+        for (var i = 0; i < comp.MaxDecals; i++)
+        {
+            if (comp.Prob < 1f && _random.NextFloat() > comp.Prob)
+                continue;
+
+            // The vector added here is just to center the generated decals to the tile the spawner is on.
+            var localPos = xform.Coordinates.Position + _random.NextVector2(comp.Radius) + new Vector2(-0.5f, -0.5f);
+            var position = new EntityCoordinates(xform.GridUid.Value, localPos);
+
+            var tileRef = _map.GetTileRef(xform.GridUid.Value, grid, position);
+            var tileRefStr = tileRef.ToString();
path: Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs
user: login: southbridge-fur
body: I am honestly not sure but it doesn't complain when it tires to place on space so I assumed it was fine. 
created_at: 2025-05-08T03:44:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+using Robust.Shared.Prototypes;
+using Content.Shared.Maps;
+using Content.Shared.Decals;
+
+namespace Content.Server.Spawners.Components;
+
+/// <summary>
+/// This component spawns decals around the entity on MapInit.
+/// See doc strings for the various parameters for more information.
+/// </summary>
+[RegisterComponent, EntityCategory("Spawner")]
+public sealed partial class RandomDecalSpawnerComponent : Component
+{
+    /// <summary>
+    /// A list of decals to randomly select from when spawning.
+    /// </summary>
+    [DataField]
+    public List<ProtoId<DecalPrototype>> Decals = new();
+
+    /// <summary>
+    /// Radius (in tiles) to spawn decals in. 0 will target only the tile the entity is on.
+    /// </summary>
+    [DataField]
+    public float Radius = 1f;
+
+    /// <summary>
+    /// Probability that a particular decal gets spawned.
+    /// </summary>
+    [DataField]
+    public float Prob = 1f;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn across the entire radius.
+    /// </summary>
+    [DataField]
+    public int MaxDecals = 1;
+
+    /// <summary>
+    /// The maximum amount of decals to spawn within a tile.
+    /// </summary>
+    /// <remarks>
+    /// A value <= 0 is considered unlimited.
+    /// </remarks>
+    [DataField]
+    public int MaxDecalsPerTile = 0;
path: Content.Server/Spawners/Components/RandomDecalSpawnerComponent.cs
user: login: southbridge-fur
body: Like this wouldn't be overridden by a default value from the decal, this codebase already uses 0 to indicate infinite or unlimited in plenty of other components, and anyone using this would have no reason to set a null value? I'll still change it I'm just really confused as to why.
created_at: 2025-05-08T03:52:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,128 @@
+using System.Numerics;
+using Content.Shared.Decals;
+using Content.Server.Spawners.Components;
+using Content.Server.Decals;
+using Content.Shared.Maps;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Random;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spawners.EntitySystems;
+
+public sealed class RandomDecalSpawnerSystem : EntitySystem
+{
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly SharedMapSystem _map = default!;
+    [Dependency] private readonly DecalSystem _decal = default!;
+    [Dependency] private readonly IEntityManager _entities = default!;
+    [Dependency] private readonly ITileDefinitionManager _tileDefs = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<RandomDecalSpawnerComponent, MapInitEvent>(OnMapInit);
+    }
+
+    private void OnMapInit(EntityUid uid, RandomDecalSpawnerComponent component, MapInitEvent args)
+    {
+        TrySpawn(uid);
+        if (component.DeleteSpawnerAfterSpawn)
+            QueueDel(uid);
+    }
+
+    public bool TrySpawn(Entity<RandomDecalSpawnerComponent?> ent)
+    {
+        if (!TryComp<RandomDecalSpawnerComponent>(ent, out var comp))
+            return false;
+
+        if (comp.Decals.Count == 0)
+            return false;
+
+        var tileBlacklist = new List<ITileDefinition>();
+        if (comp.TileBlacklist.Count > 0)
+        {
+            tileBlacklist = GetTileDefs(comp.TileBlacklist);
+        }
+
+        var xform = Transform(ent);
+        if (!TryComp<MapGridComponent>(xform.GridUid, out var grid))
+            return false;
+
+        var addedDecals = new Dictionary<String, int>();
+
+        for (var i = 0; i < comp.MaxDecals; i++)
+        {
+            if (comp.Prob < 1f && _random.NextFloat() > comp.Prob)
+                continue;
+
+            // The vector added here is just to center the generated decals to the tile the spawner is on.
+            var localPos = xform.Coordinates.Position + _random.NextVector2(comp.Radius) + new Vector2(-0.5f, -0.5f);
+            var position = new EntityCoordinates(xform.GridUid.Value, localPos);
+
+            var tileRef = _map.GetTileRef(xform.GridUid.Value, grid, position);
+            var tileRefStr = tileRef.ToString();
path: Content.Server/Spawners/EntitySystems/RandomDecalSpawnerSystem.cs
user: login: southbridge-fur
body: If I had to look up every single side effect and edge case of every single method I called I'd never get anything done. And this little bit of code took me two days to write because I had to troll through so much to be able to put any of this together. I'm begging for documentation. 
created_at: 2025-05-08T03:54:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+- type: entity
+  parent: DecalSpawnerBase
+  id: DecalSpawnerDirtBase
+  abstract: true
+  components:
+  - type: RandomDecalSpawner
+    decals:
+    - DirtLight
+    - DirtMedium
+    - DirtHeavy
+    maxDecalsPerTile: 1
+    snapPosition: true
+    zIndex: 1
+    prob: 0.8
+    color: '#FFFFFF7F'
+    cleanable: true
+    tileBlacklist: # Everything here just looks bad if it gets regular dirt on it
path: Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/dirt.yml
user: login: southbridge-fur
body: Surprisingly, we don't. I started writing out the whitelist and it already surpassed this list in length even ignoring all the concrete tiles.
created_at: 2025-05-09T03:16:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,157 @@
+- type: entity
+  parent: DecalSpawnerBase
+  id: DecalSpawnerDirtBase
+  abstract: true
+  components:
+  - type: RandomDecalSpawner
+    decals:
+    - DirtLight
+    - DirtMedium
+    - DirtHeavy
+    maxDecalsPerTile: 1
+    snapPosition: true
+    zIndex: 1
+    prob: 0.8
+    color: '#FFFFFF7F'
+    cleanable: true
+    tileBlacklist: # Everything here just looks bad if it gets regular dirt on it
path: Resources/Prototypes/Entities/Markers/Spawners/Random/Decals/dirt.yml
user: login: southbridge-fur
body: The best way to handle this might just be to add a field to the tile prototypes themselves which indicates the types of decals which can be placed on them. That however isn't easy since it would require changes to RobustToolbox for something in SS14
created_at: 2025-05-09T03:21:25Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-30T06:59:31Z
body: RSI Diff Bot; head commit 1160be45b63214bc056a0471be6bff95cc6cd95f merging into ebb1f9202740f88873f80a426447b439401c4e0c
This PR makes changes to 1 or more RSIs. Here is a summary of all changes:

<details><summary>Resources/Textures/Markers/cross.rsi</summary>
<p>

| State | Old | New | Status
| --- | --- | --- | --- |
| decal | ![]() | ![](https://raw.githubusercontent.com/southbridge-fur/space-station-14/1160be45b63214bc056a0471be6bff95cc6cd95f/Resources/Textures/Markers/cross.rsi/decal.png) | Added


</p>
</details>
Edit: diff updated after 1160be45b63214bc056a0471be6bff95cc6cd95f
author_association: CONTRIBUTOR
user: login: K-Dynamic
created_at: 2025-04-30T07:01:09Z
body: The graffiti decal should be used carefully unless you want your station to look like Barratry.

Also I recommend removing some graffiti decals from the spawner (anything denoting food, signage, electricity, etc)
author_association: CONTRIBUTOR
user: login: southbridge-fur
created_at: 2025-05-03T19:55:46Z
body: alright now all the changes have been addressed

author_association: CONTRIBUTOR
user: login: southbridge-fur
created_at: 2025-05-06T08:06:24Z
body: The rare double Heisentest 
author_association: CONTRIBUTOR
user: login: ArtisticRoomba
created_at: 2025-05-27T23:45:06Z
body: mario middle finger jpeg pointing towards trailing whitespace check
author_association: MEMBER