diff --git a/Content.Server/Pinpointer/ProximityBeeperComponent.cs b/Content.Server/Pinpointer/ProximityBeeperComponent.cs
new file mode 100644
index 0000000000..8abc7b6df7
--- /dev/null
+++ b/Content.Server/Pinpointer/ProximityBeeperComponent.cs
@@ -0,0 +1,54 @@
+using Robust.Shared.Audio;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Pinpointer;
+
+///
+/// This is used for an item that beeps based on
+/// proximity to a specified component.
+///
+[RegisterComponent, Access(typeof(ProximityBeeperSystem))]
+public sealed class ProximityBeeperComponent : Component
+{
+ ///
+ /// Whether or not it's on.
+ ///
+ [DataField("enabled")]
+ public bool Enabled;
+
+ ///
+ /// The target component that is being searched for
+ ///
+ [DataField("component", required: true), ViewVariables(VVAccess.ReadWrite)]
+ public string Component = default!;
+
+ ///
+ /// The farthest distance a target can be for the beep to occur
+ ///
+ [DataField("maximumDistance"), ViewVariables(VVAccess.ReadWrite)]
+ public float MaximumDistance = 10f;
+
+ ///
+ /// The maximum interval between beeps.
+ ///
+ [DataField("maxBeepInterval"), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan MaxBeepInterval = TimeSpan.FromSeconds(1.5f);
+
+ ///
+ /// The minimum interval between beeps.
+ ///
+ [DataField("minBeepInterval"), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan MinBeepInterval = TimeSpan.FromSeconds(0.25f);
+
+ ///
+ /// When the next beep will occur
+ ///
+ [DataField("nextBeepTime", customTypeSerializer: typeof(TimeOffsetSerializer)), ViewVariables(VVAccess.ReadWrite)]
+ public TimeSpan NextBeepTime;
+
+ ///
+ /// The sound played when the locator beeps.
+ ///
+ [DataField("beepSound")]
+ public SoundSpecifier? BeepSound;
+}
diff --git a/Content.Server/Pinpointer/ProximityBeeperSystem.cs b/Content.Server/Pinpointer/ProximityBeeperSystem.cs
new file mode 100644
index 0000000000..472a50fb23
--- /dev/null
+++ b/Content.Server/Pinpointer/ProximityBeeperSystem.cs
@@ -0,0 +1,167 @@
+using Content.Server.PowerCell;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Pinpointer;
+using Robust.Server.GameObjects;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Pinpointer;
+
+///
+/// This handles logic and interaction relating to
+///
+public sealed class ProximityBeeperSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly AppearanceSystem _appearance = default!;
+ [Dependency] private readonly AudioSystem _audio = default!;
+ [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
+ [Dependency] private readonly PowerCellSystem _powerCell = default!;
+ [Dependency] private readonly TransformSystem _transform = default!;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnUseInHand);
+ SubscribeLocalEvent(OnInit);
+ SubscribeLocalEvent(OnUnpaused);
+ SubscribeLocalEvent(OnPowerCellSlotEmpty);
+ }
+ private void OnUseInHand(EntityUid uid, ProximityBeeperComponent component, UseInHandEvent args)
+ {
+ if (args.Handled)
+ return;
+
+ args.Handled = TryToggle(uid, component, args.User);
+ }
+
+ private void OnInit(EntityUid uid, ProximityBeeperComponent component, ComponentInit args)
+ {
+ if (component.NextBeepTime < _timing.CurTime)
+ component.NextBeepTime = _timing.CurTime;
+ }
+
+ private void OnUnpaused(EntityUid uid, ProximityBeeperComponent component, ref EntityUnpausedEvent args)
+ {
+ component.NextBeepTime += args.PausedTime;
+ }
+
+ private void OnPowerCellSlotEmpty(EntityUid uid, ProximityBeeperComponent component, ref PowerCellSlotEmptyEvent args)
+ {
+ if (component.Enabled)
+ TryDisable(uid, component);
+ }
+
+ ///
+ /// Beeps the proximitybeeper as well as sets the time for the next beep
+ /// based on proximity to entities with the target component.
+ ///
+ public void UpdateBeep(EntityUid uid, ProximityBeeperComponent? component = null, bool playBeep = true)
+ {
+ if (!Resolve(uid, ref component))
+ return;
+
+ if (!component.Enabled)
+ {
+ component.NextBeepTime += component.MinBeepInterval;
+ return;
+ }
+
+ var xformQuery = GetEntityQuery();
+ var xform = xformQuery.GetComponent(uid);
+ var comp = EntityManager.ComponentFactory.GetRegistration(component.Component).Type;
+ float? closestDistance = null;
+ foreach (var targetXform in _entityLookup.GetComponentsInRange(xform.MapPosition, component.MaximumDistance))
+ {
+ // forgive me father, for i have sinned.
+ var ent = targetXform.Owner;
+
+ if (!HasComp(ent, comp))
+ continue;
+
+ var dist = (_transform.GetWorldPosition(xform, xformQuery) - _transform.GetWorldPosition(targetXform, xformQuery)).Length;
+ if (dist >= (closestDistance ?? float.MaxValue))
+ continue;
+ closestDistance = dist;
+ }
+
+ if (closestDistance is not { } distance)
+ return;
+
+ if (playBeep)
+ _audio.PlayPvs(component.BeepSound, uid);
+
+ var scalingFactor = distance / component.MaximumDistance;
+ var interval = (component.MaxBeepInterval - component.MinBeepInterval) * scalingFactor + component.MinBeepInterval;
+ component.NextBeepTime += interval;
+ }
+
+ ///
+ /// Enables the proximity beeper
+ ///
+ public bool TryEnable(EntityUid uid, ProximityBeeperComponent? component = null, EntityUid? user = null)
+ {
+ if (!Resolve(uid, ref component))
+ return false;
+
+ TryComp(uid, out var draw);
+
+ if (!_powerCell.HasActivatableCharge(uid, battery: draw, user: user))
+ return false;
+
+ component.Enabled = true;
+ _appearance.SetData(uid, ProximityBeeperVisuals.Enabled, true);
+ component.NextBeepTime = _timing.CurTime;
+ UpdateBeep(uid, component, false);
+ if (draw != null)
+ draw.Enabled = true;
+ return true;
+ }
+
+ ///
+ /// Disables the proximity beeper
+ ///
+ public bool TryDisable(EntityUid uid, ProximityBeeperComponent? component = null)
+ {
+ if (!Resolve(uid, ref component))
+ return false;
+
+ if (!component.Enabled)
+ return false;
+
+ component.Enabled = false;
+ _appearance.SetData(uid, ProximityBeeperVisuals.Enabled, false);
+ if (TryComp(uid, out var draw))
+ draw.Enabled = true;
+ UpdateBeep(uid, component);
+ return true;
+ }
+
+ ///
+ /// toggles the proximity beeper
+ ///
+ public bool TryToggle(EntityUid uid, ProximityBeeperComponent? component = null, EntityUid? user = null)
+ {
+ if (!Resolve(uid, ref component))
+ return false;
+
+ return component.Enabled
+ ? TryDisable(uid, component)
+ : TryEnable(uid, component, user);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var beeper))
+ {
+ if (!beeper.Enabled)
+ continue;
+
+ if (_timing.CurTime < beeper.NextBeepTime)
+ continue;
+ UpdateBeep(uid, beeper);
+ }
+ }
+}
diff --git a/Content.Shared/Pinpointer/SharedProximityBeeper.cs b/Content.Shared/Pinpointer/SharedProximityBeeper.cs
new file mode 100644
index 0000000000..5163112683
--- /dev/null
+++ b/Content.Shared/Pinpointer/SharedProximityBeeper.cs
@@ -0,0 +1,9 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Pinpointer;
+
+[Serializable, NetSerializable]
+public enum ProximityBeeperVisuals : byte
+{
+ Enabled
+}
diff --git a/Resources/Audio/Items/attributions.yml b/Resources/Audio/Items/attributions.yml
index 19f084fed6..1ebbf8311f 100644
--- a/Resources/Audio/Items/attributions.yml
+++ b/Resources/Audio/Items/attributions.yml
@@ -3,6 +3,11 @@
copyright: "Created by Pól, converted to OGG and Mono by EmoGarbage"
source: "https://freesound.org/people/P%C3%B3l/sounds/385927/"
+- files: ["locator_beep.ogg"]
+ license: "CC0-1.0"
+ copyright: "Created by MATRIXXX_, converted to OGG, shortened, sped up, and pitched up by EmoGarbage404 (github)"
+ source: "https://freesound.org/people/MATRIXXX_/sounds/657947/"
+
- files: ["trayhit1.ogg"]
license: "CC-BY-SA-3.0"
copyright: "Time immemorial"
diff --git a/Resources/Audio/Items/locator_beep.ogg b/Resources/Audio/Items/locator_beep.ogg
new file mode 100644
index 0000000000..ae37c1e0ca
Binary files /dev/null and b/Resources/Audio/Items/locator_beep.ogg differ
diff --git a/Resources/Prototypes/Catalog/Research/technologies.yml b/Resources/Prototypes/Catalog/Research/technologies.yml
index 1ad09c82ba..9db86f498a 100644
--- a/Resources/Prototypes/Catalog/Research/technologies.yml
+++ b/Resources/Prototypes/Catalog/Research/technologies.yml
@@ -521,6 +521,7 @@
- ScanningModuleStockPart
- NodeScanner
- AnomalyScanner
+ - AnomalyLocator
- type: technology
name: technologies-anomaly-technology
diff --git a/Resources/Prototypes/Entities/Objects/Specific/Research/anomaly.yml b/Resources/Prototypes/Entities/Objects/Specific/Research/anomaly.yml
index ff3c7606ad..a91be8c588 100644
--- a/Resources/Prototypes/Entities/Objects/Specific/Research/anomaly.yml
+++ b/Resources/Prototypes/Entities/Objects/Specific/Research/anomaly.yml
@@ -20,3 +20,46 @@
- type: GuideHelp
guides:
- ScannersAndVessels
+
+- type: entity
+ id: AnomalyLocator
+ parent: [ BaseItem, PowerCellSlotSmallItem ]
+ name: anomaly locator
+ description: A device designed to aid in the locating of anomalies. Did you check the gas miners?
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Research/anomalylocator.rsi
+ netsync: false
+ layers:
+ - state: icon
+ - state: screen
+ shader: unshaded
+ visible: false
+ map: ["enum.PowerDeviceVisualLayers.Powered"]
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.ProximityBeeperVisuals.Enabled:
+ enum.PowerDeviceVisualLayers.Powered:
+ True: { visible: true }
+ False: { visible: false }
+ - type: PowerCellDraw
+ drawRate: 10
+ useRate: 0
+ - type: ProximityBeeper
+ component: Anomaly
+ beepSound:
+ path: "/Audio/Items/locator_beep.ogg"
+ params:
+ maxdistance: 1
+ volume: -8
+
+- type: entity
+ id: AnomalyLocatorNoBattery
+ parent: AnomalyLocator
+ suffix: No Battery
+ components:
+ - type: ItemSlots
+ slots:
+ cell_slot:
+ name: power-cell-slot-component-slot-name-default
\ No newline at end of file
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index 18bae43997..05ed6926d3 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -170,6 +170,7 @@
- ConveyorBeltAssembly
- AppraisalTool
- AnomalyScanner
+ - AnomalyLocator
- RCD
- RCDAmmo
- HydroponicsToolScythe
diff --git a/Resources/Prototypes/Recipes/Lathes/devices.yml b/Resources/Prototypes/Recipes/Lathes/devices.yml
index 135d1005ae..36b66a7cc4 100644
--- a/Resources/Prototypes/Recipes/Lathes/devices.yml
+++ b/Resources/Prototypes/Recipes/Lathes/devices.yml
@@ -49,6 +49,14 @@
Plastic: 200
Glass: 100
+- type: latheRecipe
+ id: AnomalyLocator
+ result: AnomalyLocatorNoBattery
+ completetime: 3
+ materials:
+ Steel: 400
+ Glass: 100
+
- type: latheRecipe
id: AnomalyScanner
result: AnomalyScanner
diff --git a/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/icon.png b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/icon.png
new file mode 100644
index 0000000000..1ded1594d4
Binary files /dev/null and b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/icon.png differ
diff --git a/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/inhand-left.png b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/inhand-left.png
new file mode 100644
index 0000000000..84f9ca5cc6
Binary files /dev/null and b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/inhand-left.png differ
diff --git a/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/inhand-right.png b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/inhand-right.png
new file mode 100644
index 0000000000..8c4743df71
Binary files /dev/null and b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/inhand-right.png differ
diff --git a/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/meta.json b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/meta.json
new file mode 100644
index 0000000000..c10f31b7e4
--- /dev/null
+++ b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/meta.json
@@ -0,0 +1,33 @@
+{
+ "version": 1,
+ "license": "CC0-1.0",
+ "copyright": "Created by EmoGarbage404 (github) for Space Station 14.",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "icon"
+ },
+ {
+ "name": "inhand-left",
+ "directions": 4
+ },
+ {
+ "name": "inhand-right",
+ "directions": 4
+ },
+ {
+ "name": "screen",
+ "delays": [
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ }
+ ]
+}
diff --git a/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/screen.png b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/screen.png
new file mode 100644
index 0000000000..7506f353bb
Binary files /dev/null and b/Resources/Textures/Objects/Specific/Research/anomalylocator.rsi/screen.png differ