diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml b/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml
new file mode 100644
index 0000000000..1413eff00c
--- /dev/null
+++ b/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml.cs b/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml.cs
new file mode 100644
index 0000000000..beb8344ce3
--- /dev/null
+++ b/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml.cs
@@ -0,0 +1,101 @@
+using System.Numerics;
+using Content.Client.Eye;
+using Content.Shared.Administration;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.Timing;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+
+namespace Content.Client.Administration.UI.AdminCamera;
+
+[GenerateTypedNameReferences]
+public sealed partial class AdminCameraControl : Control
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IClientGameTiming _timing = default!;
+
+ public event Action? OnFollow;
+ public event Action? OnPopoutControl;
+
+ private readonly EyeLerpingSystem _eyeLerpingSystem;
+ private readonly FixedEye _defaultEye = new();
+ private AdminCameraEuiState? _nextState;
+
+ private const float MinimumZoom = 0.1f;
+ private const float MaximumZoom = 2.0f;
+
+ public EntityUid? CurrentCamera;
+ public float Zoom = 1.0f;
+
+ public bool IsPoppedOut;
+
+ public AdminCameraControl()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ _eyeLerpingSystem = _entManager.System();
+
+ CameraView.Eye = _defaultEye;
+
+ FollowButton.OnPressed += _ => OnFollow?.Invoke();
+ PopControl.OnPressed += _ => OnPopoutControl?.Invoke();
+ CameraView.OnResized += OnResized;
+ }
+
+ private new void OnResized()
+ {
+ var width = Math.Max(CameraView.PixelWidth, (int)Math.Floor(CameraView.MinWidth));
+ var height = Math.Max(CameraView.PixelHeight, (int)Math.Floor(CameraView.MinHeight));
+
+ CameraView.ViewportSize = new Vector2i(width, height);
+ }
+
+ protected override void MouseWheel(GUIMouseWheelEventArgs args)
+ {
+ base.MouseWheel(args);
+
+ if (CameraView.Eye == null)
+ return;
+
+ Zoom = Math.Clamp(Zoom - args.Delta.Y * 0.15f * Zoom, MinimumZoom, MaximumZoom);
+ CameraView.Eye.Zoom = new Vector2(Zoom, Zoom);
+ args.Handle();
+ }
+
+ public void SetState(AdminCameraEuiState state)
+ {
+ _nextState = state;
+ }
+
+ // I know that this is awful, but I copied this from the solution editor anyways.
+ // This is needed because EUIs update before the gamestate is applied, which means it will fail to get the uid from the net entity.
+ // The suggestion from the comment in the solution editor saying to use a BUI is not ideal either:
+ // - We would need to bind the UI to an entity, but with how BUIs currently work we cannot open it in the same tick as we spawn that entity on the server.
+ // - We want the UI opened by the user session, not by their currently attached entity. Otherwise it would close in cases where admins move from one entity to another, for example when ghosting.
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ if (_nextState == null || _timing.LastRealTick < _nextState.Tick) // make sure the last gamestate has been applied
+ return;
+
+ if (!_entManager.TryGetEntity(_nextState.Camera, out var cameraUid))
+ return;
+
+ if (CurrentCamera == null)
+ {
+ _eyeLerpingSystem.AddEye(cameraUid.Value);
+ CurrentCamera = cameraUid;
+ }
+ else if (CurrentCamera != cameraUid)
+ {
+ _eyeLerpingSystem.RemoveEye(CurrentCamera.Value);
+ _eyeLerpingSystem.AddEye(cameraUid.Value);
+ CurrentCamera = cameraUid;
+ }
+
+ if (_entManager.TryGetComponent(CurrentCamera, out var eye))
+ CameraView.Eye = eye.Eye ?? _defaultEye;
+ }
+}
diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraEui.cs b/Content.Client/Administration/UI/AdminCamera/AdminCameraEui.cs
new file mode 100644
index 0000000000..908fb2740c
--- /dev/null
+++ b/Content.Client/Administration/UI/AdminCamera/AdminCameraEui.cs
@@ -0,0 +1,117 @@
+using System.Numerics;
+using Content.Client.Eui;
+using Content.Shared.Administration;
+using Content.Shared.Eui;
+using JetBrains.Annotations;
+using Robust.Client.UserInterface.Controls;
+
+namespace Content.Client.Administration.UI.AdminCamera;
+
+///
+/// Admin Eui for opening a viewport window to observe entities.
+/// Use the "Open Camera" admin verb or the "camera" command to open.
+///
+[UsedImplicitly]
+public sealed partial class AdminCameraEui : BaseEui
+{
+ private readonly AdminCameraWindow _window;
+ private readonly AdminCameraControl _control;
+
+ // If not null the camera is in "popped out" mode and is in an external window.
+ private OSWindow? _OSWindow;
+
+ // The last location the window was located at in game.
+ // Is used for getting knowing where to "pop in" external windows.
+ private Vector2 _lastLocation;
+
+ public AdminCameraEui()
+ {
+ _window = new AdminCameraWindow();
+ _control = new AdminCameraControl();
+
+ _window.Contents.AddChild(_control);
+
+ _control.OnFollow += () => SendMessage(new AdminCameraFollowMessage());
+ _window.OnClose += () =>
+ {
+ if (!_control.IsPoppedOut)
+ SendMessage(new CloseEuiMessage());
+ };
+
+ _control.OnPopoutControl += () =>
+ {
+ if (_control.IsPoppedOut)
+ PopIn();
+ else
+ PopOut();
+ };
+ }
+
+ // Pop the window out into an external OS window
+ private void PopOut()
+ {
+ _lastLocation = _window.Position;
+
+ // TODO: When there is a way to have a minimum window size, enforce something!
+ _OSWindow = new OSWindow
+ {
+ SetSize = _window.Size,
+ Title = _window.Title ?? Loc.GetString("admin-camera-window-title-placeholder"),
+ };
+
+ _OSWindow.Show();
+
+ if (_OSWindow.Root == null)
+ return;
+
+ _control.Orphan();
+ _OSWindow.Root.AddChild(_control);
+
+ _OSWindow.Closed += () =>
+ {
+ if (_control.IsPoppedOut)
+ SendMessage(new CloseEuiMessage());
+ };
+
+ _control.IsPoppedOut = true;
+ _control.PopControl.Text = Loc.GetString("admin-camera-window-pop-in");
+
+ _window.Close();
+ }
+
+ // Pop the window back into the in game window.
+ private void PopIn()
+ {
+ _control.Orphan();
+ _window.Contents.AddChild(_control);
+
+ _window.Open(_lastLocation);
+
+ _control.IsPoppedOut = false;
+ _control.PopControl.Text = Loc.GetString("admin-camera-window-pop-out");
+
+ _OSWindow?.Close();
+ _OSWindow = null;
+ }
+
+ public override void Opened()
+ {
+ base.Opened();
+ _window.OpenCentered();
+ }
+
+ public override void Closed()
+ {
+ base.Closed();
+ _window.Close();
+ }
+
+ public override void HandleState(EuiStateBase baseState)
+ {
+ if (baseState is not AdminCameraEuiState state)
+ return;
+
+ _window.SetState(state);
+ _control.SetState(state);
+ }
+}
diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml b/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml
new file mode 100644
index 0000000000..87583cef97
--- /dev/null
+++ b/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml
@@ -0,0 +1,6 @@
+
+
diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml.cs b/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml.cs
new file mode 100644
index 0000000000..07a6e2156e
--- /dev/null
+++ b/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml.cs
@@ -0,0 +1,23 @@
+using Content.Shared.Administration;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Administration.UI.AdminCamera;
+
+[GenerateTypedNameReferences]
+public sealed partial class AdminCameraWindow : DefaultWindow
+{
+ public AdminCameraWindow()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ ContentsContainer.Margin = new Thickness(5, 0, 5, 0);
+ }
+
+ public void SetState(AdminCameraEuiState state)
+ {
+ Title = Loc.GetString("admin-camera-window-title", ("name", state.Name));
+ }
+}
diff --git a/Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml b/Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml
index 4791dcf6aa..9c54049da3 100644
--- a/Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml
+++ b/Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml
@@ -31,6 +31,7 @@
+
diff --git a/Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml.cs b/Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml.cs
index 14311851b4..62cb64050e 100644
--- a/Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml.cs
+++ b/Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml.cs
@@ -18,6 +18,7 @@ public sealed partial class PlayerPanel : FancyWindow
public event Action? OnOpenBans;
public event Action? OnAhelp;
public event Action? OnKick;
+ public event Action? OnCamera;
public event Action? OnOpenBanPanel;
public event Action? OnWhitelistToggle;
public event Action? OnFollow;
@@ -33,26 +34,27 @@ public sealed partial class PlayerPanel : FancyWindow
public PlayerPanel(IClientAdminManager adminManager)
{
- RobustXamlLoader.Load(this);
- _adminManager = adminManager;
+ RobustXamlLoader.Load(this);
+ _adminManager = adminManager;
- UsernameCopyButton.OnPressed += _ => OnUsernameCopy?.Invoke(TargetUsername ?? "");
- BanButton.OnPressed += _ => OnOpenBanPanel?.Invoke(TargetPlayer);
- KickButton.OnPressed += _ => OnKick?.Invoke(TargetUsername);
- NotesButton.OnPressed += _ => OnOpenNotes?.Invoke(TargetPlayer);
- ShowBansButton.OnPressed += _ => OnOpenBans?.Invoke(TargetPlayer);
- AhelpButton.OnPressed += _ => OnAhelp?.Invoke(TargetPlayer);
- WhitelistToggle.OnPressed += _ =>
- {
- OnWhitelistToggle?.Invoke(TargetPlayer, _isWhitelisted);
- SetWhitelisted(!_isWhitelisted);
- };
- FollowButton.OnPressed += _ => OnFollow?.Invoke();
- FreezeButton.OnPressed += _ => OnFreeze?.Invoke();
- FreezeAndMuteToggleButton.OnPressed += _ => OnFreezeAndMuteToggle?.Invoke();
- LogsButton.OnPressed += _ => OnLogs?.Invoke();
- DeleteButton.OnPressed += _ => OnDelete?.Invoke();
- RejuvenateButton.OnPressed += _ => OnRejuvenate?.Invoke();
+ UsernameCopyButton.OnPressed += _ => OnUsernameCopy?.Invoke(TargetUsername ?? "");
+ BanButton.OnPressed += _ => OnOpenBanPanel?.Invoke(TargetPlayer);
+ KickButton.OnPressed += _ => OnKick?.Invoke(TargetUsername);
+ CameraButton.OnPressed += _ => OnCamera?.Invoke(TargetUsername);
+ NotesButton.OnPressed += _ => OnOpenNotes?.Invoke(TargetPlayer);
+ ShowBansButton.OnPressed += _ => OnOpenBans?.Invoke(TargetPlayer);
+ AhelpButton.OnPressed += _ => OnAhelp?.Invoke(TargetPlayer);
+ WhitelistToggle.OnPressed += _ =>
+ {
+ OnWhitelistToggle?.Invoke(TargetPlayer, _isWhitelisted);
+ SetWhitelisted(!_isWhitelisted);
+ };
+ FollowButton.OnPressed += _ => OnFollow?.Invoke();
+ FreezeButton.OnPressed += _ => OnFreeze?.Invoke();
+ FreezeAndMuteToggleButton.OnPressed += _ => OnFreezeAndMuteToggle?.Invoke();
+ LogsButton.OnPressed += _ => OnLogs?.Invoke();
+ DeleteButton.OnPressed += _ => OnDelete?.Invoke();
+ RejuvenateButton.OnPressed += _ => OnRejuvenate?.Invoke();
}
public void SetUsername(string player)
@@ -122,6 +124,7 @@ public sealed partial class PlayerPanel : FancyWindow
{
BanButton.Disabled = !_adminManager.CanCommand("banpanel");
KickButton.Disabled = !_adminManager.CanCommand("kick");
+ CameraButton.Disabled = !_adminManager.CanCommand("camera");
NotesButton.Disabled = !_adminManager.CanCommand("adminnotes");
ShowBansButton.Disabled = !_adminManager.CanCommand("banlist");
WhitelistToggle.Disabled =
diff --git a/Content.Client/Administration/UI/PlayerPanel/PlayerPanelEui.cs b/Content.Client/Administration/UI/PlayerPanel/PlayerPanelEui.cs
index 2129fa5b0c..8c8183ef22 100644
--- a/Content.Client/Administration/UI/PlayerPanel/PlayerPanelEui.cs
+++ b/Content.Client/Administration/UI/PlayerPanel/PlayerPanelEui.cs
@@ -15,7 +15,7 @@ public sealed class PlayerPanelEui : BaseEui
[Dependency] private readonly IClientAdminManager _admin = default!;
[Dependency] private readonly IClipboardManager _clipboard = default!;
- private PlayerPanel PlayerPanel { get; }
+ private PlayerPanel PlayerPanel { get; }
public PlayerPanelEui()
{
@@ -25,6 +25,7 @@ public sealed class PlayerPanelEui : BaseEui
PlayerPanel.OnOpenNotes += id => _console.ExecuteCommand($"adminnotes \"{id}\"");
// Kick command does not support GUIDs
PlayerPanel.OnKick += username => _console.ExecuteCommand($"kick \"{username}\"");
+ PlayerPanel.OnCamera += username => _console.ExecuteCommand($"camera \"{username}\"");
PlayerPanel.OnOpenBanPanel += id => _console.ExecuteCommand($"banpanel \"{id}\"");
PlayerPanel.OnOpenBans += id => _console.ExecuteCommand($"banlist \"{id}\"");
PlayerPanel.OnAhelp += id => _console.ExecuteCommand($"openahelp \"{id}\"");
@@ -37,7 +38,7 @@ public sealed class PlayerPanelEui : BaseEui
PlayerPanel.OnFreeze += () => SendMessage(new PlayerPanelFreezeMessage());
PlayerPanel.OnLogs += () => SendMessage(new PlayerPanelLogsMessage());
PlayerPanel.OnRejuvenate += () => SendMessage(new PlayerPanelRejuvenationMessage());
- PlayerPanel.OnDelete+= () => SendMessage(new PlayerPanelDeleteMessage());
+ PlayerPanel.OnDelete += () => SendMessage(new PlayerPanelDeleteMessage());
PlayerPanel.OnFollow += () => SendMessage(new PlayerPanelFollowMessage());
PlayerPanel.OnClose += () => SendMessage(new CloseEuiMessage());
diff --git a/Content.Server/Administration/Commands/CameraCommand.cs b/Content.Server/Administration/Commands/CameraCommand.cs
new file mode 100644
index 0000000000..25837ac709
--- /dev/null
+++ b/Content.Server/Administration/Commands/CameraCommand.cs
@@ -0,0 +1,58 @@
+using Content.Server.Administration.UI;
+using Content.Server.EUI;
+using Content.Shared.Administration;
+using Robust.Server.Player;
+using Robust.Shared.Console;
+
+namespace Content.Server.Administration.Commands;
+
+[AdminCommand(AdminFlags.Admin)]
+public sealed class CameraCommand : LocalizedCommands
+{
+ [Dependency] private readonly EuiManager _eui = default!;
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+
+ public override string Command => "camera";
+
+ public override void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (shell.Player is not { } user)
+ {
+ shell.WriteError(Loc.GetString("shell-cannot-run-command-from-server"));
+ return;
+ }
+
+ if (args.Length != 1)
+ {
+ shell.WriteError(Loc.GetString("shell-wrong-arguments-number"));
+ return;
+ }
+
+ if (!NetEntity.TryParse(args[0], out var targetNetId) || !_entManager.TryGetEntity(targetNetId, out var targetUid))
+ {
+ if (!_playerManager.TryGetSessionByUsername(args[0], out var player)
+ || player.AttachedEntity == null)
+ {
+ shell.WriteError(Loc.GetString("cmd-camera-wrong-argument"));
+ return;
+ }
+ targetUid = player.AttachedEntity.Value;
+ }
+
+ var ui = new AdminCameraEui(targetUid.Value);
+ _eui.OpenEui(ui, user);
+ }
+
+ public override CompletionResult GetCompletion(IConsoleShell shell, string[] args)
+ {
+ if (args.Length == 1)
+ {
+ return CompletionResult.FromHintOptions(
+ CompletionHelper.SessionNames(players: _playerManager),
+ Loc.GetString("cmd-camera-hint"));
+ }
+
+ return CompletionResult.Empty;
+ }
+}
diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.cs b/Content.Server/Administration/Systems/AdminVerbSystem.cs
index 19bcfd26f6..61e3013bd9 100644
--- a/Content.Server/Administration/Systems/AdminVerbSystem.cs
+++ b/Content.Server/Administration/Systems/AdminVerbSystem.cs
@@ -388,6 +388,22 @@ namespace Content.Server.Administration.Systems
Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Interface/Actions/actions_borg.rsi"), "state-laws"),
});
}
+
+ // open camera
+ args.Verbs.Add(new Verb()
+ {
+ Priority = 10,
+ Text = Loc.GetString("admin-verbs-camera"),
+ Message = Loc.GetString("admin-verbs-camera-description"),
+ Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/vv.svg.192dpi.png")),
+ Category = VerbCategory.Admin,
+ Act = () =>
+ {
+ var ui = new AdminCameraEui(args.Target);
+ _euiManager.OpenEui(ui, player);
+ },
+ Impact = LogImpact.Low
+ });
}
}
diff --git a/Content.Server/Administration/UI/AdminCameraEui.cs b/Content.Server/Administration/UI/AdminCameraEui.cs
new file mode 100644
index 0000000000..5230933c82
--- /dev/null
+++ b/Content.Server/Administration/UI/AdminCameraEui.cs
@@ -0,0 +1,97 @@
+using Content.Server.Administration.Managers;
+using Content.Server.EUI;
+using Content.Shared.Administration;
+using Content.Shared.Eui;
+using Content.Shared.Follower;
+using Content.Shared.Coordinates;
+using Robust.Server.GameStates;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using JetBrains.Annotations;
+
+namespace Content.Server.Administration.UI;
+
+///
+/// Admin Eui for opening a viewport window to observe entities.
+/// Use the "Open Camera" admin verb or the "camera" command to open.
+///
+[UsedImplicitly]
+public sealed partial class AdminCameraEui : BaseEui
+{
+ [Dependency] private readonly IAdminManager _admin = default!;
+ [Dependency] private readonly IEntityManager _entityManager = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ private readonly FollowerSystem _follower = default!;
+ private readonly PvsOverrideSystem _pvs = default!;
+ private readonly SharedViewSubscriberSystem _viewSubscriber = default!;
+
+ private static readonly EntProtoId CameraProtoId = "AdminCamera";
+
+ private readonly EntityUid _target;
+ private EntityUid? _camera;
+
+
+ public AdminCameraEui(EntityUid target)
+ {
+ IoCManager.InjectDependencies(this);
+ _follower = _entityManager.System();
+ _pvs = _entityManager.System();
+ _viewSubscriber = _entityManager.System();
+
+ _target = target;
+ }
+
+ public override void Opened()
+ {
+ base.Opened();
+
+ _camera = CreateCamera(_target, Player);
+ StateDirty();
+ }
+
+ public override void Closed()
+ {
+ base.Closed();
+
+ _entityManager.DeleteEntity(_camera);
+ }
+
+ public override void HandleMessage(EuiMessageBase msg)
+ {
+ base.HandleMessage(msg);
+
+ switch (msg)
+ {
+ case AdminCameraFollowMessage:
+ if (!_admin.HasAdminFlag(Player, AdminFlags.Admin) || Player.AttachedEntity == null)
+ return;
+ _follower.StartFollowingEntity(Player.AttachedEntity.Value, _target);
+ break;
+ default:
+ break;
+ }
+ }
+
+ public override EuiStateBase GetNewState()
+ {
+ var name = _entityManager.GetComponent(_target).EntityName;
+ var netEnt = _entityManager.GetNetEntity(_camera);
+ return new AdminCameraEuiState(netEnt, name, _timing.CurTick);
+ }
+
+ private EntityUid CreateCamera(EntityUid target, ICommonSession observer)
+ {
+ // Spawn a camera entity attached to the target.
+ var coords = target.ToCoordinates();
+ var camera = _entityManager.SpawnAttachedTo(CameraProtoId, coords);
+
+ // Allow the user to see the entities near the camera.
+ // This also force sends the camera entity to the user, overriding the visibility flags.
+ // (The camera entity has its visibility flags set to VisibilityFlags.Admin so that cheat clients can't see it)
+ _viewSubscriber.AddViewSubscriber(camera, observer);
+
+ return camera;
+ }
+}
diff --git a/Content.Server/Power/EntitySystems/BatteryInterfaceSystem.cs b/Content.Server/Power/EntitySystems/BatteryInterfaceSystem.cs
index 6ce19cfaf6..33e3f8ff2c 100644
--- a/Content.Server/Power/EntitySystems/BatteryInterfaceSystem.cs
+++ b/Content.Server/Power/EntitySystems/BatteryInterfaceSystem.cs
@@ -1,4 +1,6 @@
-using Content.Server.Power.Components;
+using Content.Server.Administration.Logs;
+using Content.Server.Power.Components;
+using Content.Shared.Database;
using Content.Shared.Power;
using Robust.Server.GameObjects;
@@ -19,6 +21,7 @@ namespace Content.Server.Power.EntitySystems;
///
public sealed class BatteryInterfaceSystem : EntitySystem
{
+ [Dependency] private readonly IAdminLogManager _adminLog = default!;
[Dependency] private readonly UserInterfaceSystem _uiSystem = null!;
public override void Initialize()
@@ -43,12 +46,16 @@ public sealed class BatteryInterfaceSystem : EntitySystem
{
var netBattery = Comp(ent);
netBattery.CanCharge = args.On;
+
+ _adminLog.Add(LogType.Action,$"{ToPrettyString(args.Actor):actor} set input breaker to {args.On} on {ToPrettyString(ent):target}");
}
private void HandleSetOutputBreaker(Entity ent, ref BatterySetOutputBreakerMessage args)
{
var netBattery = Comp(ent);
netBattery.CanDischarge = args.On;
+
+ _adminLog.Add(LogType.Action,$"{ToPrettyString(args.Actor):actor} set output breaker to {args.On} on {ToPrettyString(ent):target}");
}
private void HandleSetChargeRate(Entity ent, ref BatterySetChargeRateMessage args)
diff --git a/Content.Shared/Administration/AdminCameraEuiState.cs b/Content.Shared/Administration/AdminCameraEuiState.cs
new file mode 100644
index 0000000000..ae41f3aeb0
--- /dev/null
+++ b/Content.Shared/Administration/AdminCameraEuiState.cs
@@ -0,0 +1,27 @@
+using Content.Shared.Eui;
+using Robust.Shared.Serialization;
+using Robust.Shared.Timing;
+
+namespace Content.Shared.Administration;
+
+[Serializable, NetSerializable]
+public sealed partial class AdminCameraEuiState(NetEntity? camera, string name, GameTick tick) : EuiStateBase
+{
+ ///
+ /// The camera entity we will use for the window.
+ ///
+ public readonly NetEntity? Camera = camera;
+
+ ///
+ /// The name of the observed entity.
+ ///
+ public readonly string Name = name;
+
+ ///
+ /// The current tick time, needed for cursed reasons.
+ ///
+ public readonly GameTick Tick = tick;
+}
+
+[Serializable, NetSerializable]
+public sealed partial class AdminCameraFollowMessage : EuiMessageBase;
diff --git a/Content.Shared/Eye/VisibilityFlags.cs b/Content.Shared/Eye/VisibilityFlags.cs
index 432e80dd58..6cf8b18fca 100644
--- a/Content.Shared/Eye/VisibilityFlags.cs
+++ b/Content.Shared/Eye/VisibilityFlags.cs
@@ -6,9 +6,10 @@ namespace Content.Shared.Eye
[FlagsFor(typeof(VisibilityMaskLayer))]
public enum VisibilityFlags : int
{
- None = 0,
+ None = 0,
Normal = 1 << 0,
- Ghost = 1 << 1,
- Subfloor = 1 << 2,
+ Ghost = 1 << 1, // Observers and revenants.
+ Subfloor = 1 << 2, // Pipes, disposal chutes, cables etc. while hidden under tiles. Can be revealed with a t-ray.
+ Admin = 1 << 3, // Reserved for admins in stealth mode and admin tools.
}
}
diff --git a/Content.Shared/Follower/FollowerSystem.cs b/Content.Shared/Follower/FollowerSystem.cs
index d8e34fb3ee..b9c2f4bece 100644
--- a/Content.Shared/Follower/FollowerSystem.cs
+++ b/Content.Shared/Follower/FollowerSystem.cs
@@ -189,6 +189,9 @@ public sealed class FollowerSystem : EntitySystem
/// The entity to be followed
public void StartFollowingEntity(EntityUid follower, EntityUid entity)
{
+ if (follower == entity || TerminatingOrDeleted(entity))
+ return;
+
// No recursion for you
var targetXform = Transform(entity);
while (targetXform.ParentUid.IsValid())
diff --git a/Content.Shared/KillTome/KillTomeComponent.cs b/Content.Shared/KillTome/KillTomeComponent.cs
new file mode 100644
index 0000000000..266ff1a8f8
--- /dev/null
+++ b/Content.Shared/KillTome/KillTomeComponent.cs
@@ -0,0 +1,38 @@
+using Content.Shared.Damage;
+using Content.Shared.Damage.Prototypes;
+using Content.Shared.FixedPoint;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.KillTome;
+
+///
+/// Paper with that component is KillTome.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class KillTomeComponent : Component
+{
+ ///
+ /// if delay is not specified, it will use this default value
+ ///
+ [DataField, AutoNetworkedField]
+ public TimeSpan DefaultKillDelay = TimeSpan.FromSeconds(40);
+
+ ///
+ /// Damage specifier that will be used to kill the target.
+ ///
+ [DataField, AutoNetworkedField]
+ public DamageSpecifier Damage = new()
+ {
+ DamageDict = new Dictionary
+ {
+ { "Blunt", 200 }
+ }
+ };
+
+ ///
+ /// to keep a track of already killed people so they won't be killed again
+ ///
+ [DataField]
+ public HashSet KilledEntities = [];
+}
diff --git a/Content.Shared/KillTome/KillTomeSystem.cs b/Content.Shared/KillTome/KillTomeSystem.cs
new file mode 100644
index 0000000000..bd49c483d9
--- /dev/null
+++ b/Content.Shared/KillTome/KillTomeSystem.cs
@@ -0,0 +1,187 @@
+using System.Diagnostics.CodeAnalysis;
+using Content.Shared.Administration.Logs;
+using Content.Shared.Damage;
+using Content.Shared.Database;
+using Content.Shared.Humanoid;
+using Content.Shared.Mobs;
+using Content.Shared.Mobs.Components;
+using Content.Shared.NameModifier.EntitySystems;
+using Content.Shared.Paper;
+using Content.Shared.Popups;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.KillTome;
+
+///
+/// This handles KillTome functionality.
+///
+
+/// Kill Tome Rules:
+// 1. The humanoid whose name is written in this note shall die.
+// 2. If the name is shared by multiple humanoids, a random humanoid with that name will die.
+// 3. Each name shall be written on a new line.
+// 4. Names must be written in the format: "Name, Delay (in seconds)" (e.g., John Doe, 40).
+// 5. A humanoid can be killed by the same Kill Tome only once.
+public sealed class KillTomeSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly SharedPopupSystem _popupSystem = default!;
+ [Dependency] private readonly DamageableSystem _damageSystem = default!;
+ [Dependency] private readonly ISharedAdminLogManager _adminLogs = default!;
+ [Dependency] private readonly NameModifierSystem _nameModifierSystem = default!;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnPaperAfterWriteInteract);
+ }
+
+ public override void Update(float frameTime)
+ {
+ // Getting all the entities that are targeted by Kill Tome and checking if their kill time has passed.
+ // If it has, we kill them and remove the KillTomeTargetComponent.
+ var query = EntityQueryEnumerator();
+
+ while (query.MoveNext(out var uid, out var targetComp))
+ {
+ if (_gameTiming.CurTime < targetComp.KillTime)
+ continue;
+
+ // The component doesn't get removed fast enough and the update loop will run through it a few more times.
+ // This check is here to ensure it will not spam popups or kill you several times over.
+ if (targetComp.Dead)
+ continue;
+
+ Kill(uid, targetComp);
+
+ _popupSystem.PopupPredicted(Loc.GetString("killtome-death"),
+ Loc.GetString("killtome-death-others", ("target", uid)),
+ uid,
+ uid,
+ PopupType.LargeCaution);
+
+ targetComp.Dead = true;
+
+ RemCompDeferred(uid);
+ }
+ }
+
+ private void OnPaperAfterWriteInteract(Entity ent, ref PaperAfterWriteEvent args)
+ {
+ // if the entity is not a paper, we don't do anything
+ if (!TryComp(ent.Owner, out var paper))
+ return;
+
+ var content = paper.Content;
+
+ var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+ var showPopup = false;
+
+ foreach (var line in lines)
+ {
+ if (string.IsNullOrEmpty(line))
+ continue;
+
+ var parts = line.Split(',', 2, StringSplitOptions.RemoveEmptyEntries);
+
+ var name = parts[0].Trim();
+
+ var delay = ent.Comp.DefaultKillDelay;
+
+ if (parts.Length == 2 && Parse.TryInt32(parts[1].Trim(), out var parsedDelay) && parsedDelay > 0)
+ delay = TimeSpan.FromSeconds(parsedDelay);
+
+ if (!CheckIfEligible(name, ent.Comp, out var uid))
+ {
+ continue;
+ }
+
+ // Compiler will complain if we don't check for null here.
+ if (uid is not { } realUid)
+ continue;
+
+ showPopup = true;
+
+ EnsureComp(realUid, out var targetComp);
+
+ targetComp.KillTime = _gameTiming.CurTime + delay;
+ targetComp.Damage = ent.Comp.Damage;
+
+ Dirty(realUid, targetComp);
+
+ ent.Comp.KilledEntities.Add(realUid);
+
+ Dirty(ent);
+
+ _adminLogs.Add(LogType.Chat,
+ LogImpact.High,
+ $"{ToPrettyString(args.Actor)} has written {ToPrettyString(uid)}'s name in Kill Tome.");
+ }
+
+ // If we have written at least one eligible name, we show the popup (So the player knows death note worked).
+ if (showPopup)
+ _popupSystem.PopupEntity(Loc.GetString("killtome-kill-success"), ent.Owner, args.Actor, PopupType.Large);
+ }
+
+ // A person to be killed by KillTome must:
+ // 1. be with the name
+ // 2. have HumanoidAppearanceComponent (so it targets only humanoids, obv)
+ // 3. not be already dead
+ // 4. not be already killed by Kill Tome
+
+ // If all these conditions are met, we return true and the entityUid of the person to kill.
+ private bool CheckIfEligible(string name, KillTomeComponent comp, [NotNullWhen(true)] out EntityUid? entityUid)
+ {
+ if (!TryFindEntityByName(name, out var uid) ||
+ !TryComp(uid, out var mob))
+ {
+ entityUid = null;
+ return false;
+ }
+
+ if (uid is not { } realUid)
+ {
+ entityUid = null;
+ return false;
+ }
+
+ if (comp.KilledEntities.Contains(realUid))
+ {
+ entityUid = null;
+ return false;
+ }
+
+ if (mob.CurrentState == MobState.Dead)
+ {
+ entityUid = null;
+ return false;
+ }
+
+ entityUid = uid;
+ return true;
+ }
+
+ private bool TryFindEntityByName(string name, [NotNullWhen(true)] out EntityUid? entityUid)
+ {
+ var query = EntityQueryEnumerator();
+
+ while (query.MoveNext(out var uid, out _))
+ {
+ if (!_nameModifierSystem.GetBaseName(uid).Equals(name, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ entityUid = uid;
+ return true;
+ }
+
+ entityUid = null;
+ return false;
+ }
+
+ private void Kill(EntityUid uid, KillTomeTargetComponent comp)
+ {
+ _damageSystem.TryChangeDamage(uid, comp.Damage, true);
+ }
+}
diff --git a/Content.Shared/KillTome/KillTomeTargetComponent.cs b/Content.Shared/KillTome/KillTomeTargetComponent.cs
new file mode 100644
index 0000000000..14a573b75f
--- /dev/null
+++ b/Content.Shared/KillTome/KillTomeTargetComponent.cs
@@ -0,0 +1,41 @@
+using Content.Shared.Damage;
+using Content.Shared.FixedPoint;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.KillTome;
+
+///
+/// Entity with this component is a Kill Tome target.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause]
+public sealed partial class KillTomeTargetComponent : Component
+{
+ ///
+ /// Damage that will be dealt to the target.
+ ///
+ [DataField, AutoNetworkedField]
+ public DamageSpecifier Damage = new()
+ {
+ DamageDict = new Dictionary
+ {
+ { "Blunt", 200 }
+ }
+ };
+
+ ///
+ /// The time when the target is killed.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField]
+ [AutoPausedField]
+ public TimeSpan KillTime = TimeSpan.Zero;
+
+ ///
+ /// Indicates this target has been killed by the killtome.
+ ///
+ [DataField, AutoNetworkedField]
+ public bool Dead;
+
+ // Disallows cheat clients from seeing who is about to die to the killtome.
+ public override bool SendOnlyToOwner => true;
+}
diff --git a/Content.Shared/Paper/PaperSystem.cs b/Content.Shared/Paper/PaperSystem.cs
index 75496d93b4..6a181e4ae9 100644
--- a/Content.Shared/Paper/PaperSystem.cs
+++ b/Content.Shared/Paper/PaperSystem.cs
@@ -187,6 +187,7 @@ public sealed class PaperSystem : EntitySystem
{
var ev = new PaperWriteAttemptEvent(entity.Owner);
RaiseLocalEvent(args.Actor, ref ev);
+
if (ev.Cancelled)
return;
@@ -211,6 +212,9 @@ public sealed class PaperSystem : EntitySystem
entity.Comp.Mode = PaperAction.Read;
UpdateUserInterface(entity);
+
+ var writeAfterEv = new PaperAfterWriteEvent(args.Actor);
+ RaiseLocalEvent(entity.Owner, ref writeAfterEv);
}
private void OnRandomPaperContentMapInit(Entity ent, ref MapInitEvent args)
@@ -319,6 +323,14 @@ public record struct PaperWriteEvent(EntityUid User, EntityUid Paper);
///
/// Cancellable event for attempting to write on a piece of paper.
///
-/// The paper that the writing will take place on.
+/// The paper that the writing will take place on.
[ByRefEvent]
public record struct PaperWriteAttemptEvent(EntityUid Paper, string? FailReason = null, bool Cancelled = false);
+
+///
+/// Event raised on paper after it was written on by someone.
+///
+/// Entity that wrote something on the paper.
+[ByRefEvent]
+public readonly record struct PaperAfterWriteEvent(EntityUid Actor);
+
diff --git a/Resources/Changelog/Admin.yml b/Resources/Changelog/Admin.yml
index d12d76a9d4..168a6c2c52 100644
--- a/Resources/Changelog/Admin.yml
+++ b/Resources/Changelog/Admin.yml
@@ -1285,5 +1285,13 @@ Entries:
id: 156
time: '2025-07-24T15:13:29.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/39025
+- author: slarticodefast, beck-thompson
+ changes:
+ - message: Added the "Open Camera" admin verb and the "camera" console command which
+ allow you to observe entities in separate window using a remote camera.
+ type: Add
+ id: 157
+ time: '2025-07-25T16:53:01.0000000+00:00'
+ url: https://github.com/space-wizards/space-station-14/pull/36969
Name: Admin
Order: 2
diff --git a/Resources/Changelog/Changelog.yml b/Resources/Changelog/Changelog.yml
index 29be0a679d..9b70794553 100644
--- a/Resources/Changelog/Changelog.yml
+++ b/Resources/Changelog/Changelog.yml
@@ -1,32 +1,4 @@
Entries:
-- author: Entvari
- changes:
- - message: Added a new Wizard headset.
- type: Add
- - message: The Wizard now starts with the Wizard headset.
- type: Tweak
- id: 8295
- time: '2025-04-20T21:38:30.0000000+00:00'
- url: https://github.com/space-wizards/space-station-14/pull/35732
-- author: aada
- changes:
- - message: Filter categories added to the circuit imprinter.
- type: Add
- id: 8296
- time: '2025-04-20T22:27:49.0000000+00:00'
- url: https://github.com/space-wizards/space-station-14/pull/35713
-- author: ScarKy0
- changes:
- - message: Mail spawning has been reworked. The mail teleporter now has to be manually
- emptied to take all the mail out. (You can do it by alt-clicking it or using
- a verb)
- type: Tweak
- - message: The mail teleporter now has an internal capacity of 20. It will not spawn
- mail past that until emptied by someone.
- type: Add
- id: 8297
- time: '2025-04-21T09:32:18.0000000+00:00'
- url: https://github.com/space-wizards/space-station-14/pull/36774
- author: ScarKy0
changes:
- message: Default cargo split is now 50% instead of 75%. Lockboxes remain unchanged.
@@ -3911,3 +3883,28 @@
id: 8806
time: '2025-07-24T11:24:19.0000000+00:00'
url: https://github.com/space-wizards/space-station-14/pull/39177
+- author: SlamBamActionman
+ changes:
+ - message: Potassium-water explosion damage scaling has been tweaked to be less
+ potent.
+ type: Tweak
+ id: 8807
+ time: '2025-07-25T16:04:27.0000000+00:00'
+ url: https://github.com/space-wizards/space-station-14/pull/37924
+- author: SlamBamActionman
+ changes:
+ - message: Chemistry foam and smoke reactions now trigger after regular mix reactions.
+ type: Tweak
+ - message: Foam, smoke and explosion reactions no longer transfer the reagents'
+ energies to the container.
+ type: Tweak
+ id: 8808
+ time: '2025-07-25T16:33:44.0000000+00:00'
+ url: https://github.com/space-wizards/space-station-14/pull/37915
+- author: Quantum-cross
+ changes:
+ - message: pinpointer will no longer trigger sufferers of OCD
+ type: Fix
+ id: 8809
+ time: '2025-07-25T19:46:42.0000000+00:00'
+ url: https://github.com/space-wizards/space-station-14/pull/38657
diff --git a/Resources/Locale/en-US/administration/admin-verbs.ftl b/Resources/Locale/en-US/administration/admin-verbs.ftl
index a89397bd65..13be0a876d 100644
--- a/Resources/Locale/en-US/administration/admin-verbs.ftl
+++ b/Resources/Locale/en-US/administration/admin-verbs.ftl
@@ -8,6 +8,8 @@ admin-verbs-teleport-here = Teleport Here
admin-verbs-freeze = Freeze
admin-verbs-freeze-and-mute = Freeze And Mute
admin-verbs-unfreeze = Unfreeze
+admin-verbs-camera = Open Camera
+admin-verbs-camera-description = Open a camera window that follows the selected entity.
admin-verbs-erase = Erase
admin-verbs-erase-description = Removes the player from the round and crew manifest and deletes their chat messages.
Their items are dropped on the ground.
diff --git a/Resources/Locale/en-US/administration/commands/camera.ftl b/Resources/Locale/en-US/administration/commands/camera.ftl
new file mode 100644
index 0000000000..98839683e0
--- /dev/null
+++ b/Resources/Locale/en-US/administration/commands/camera.ftl
@@ -0,0 +1,5 @@
+cmd-camera-desc = Opens a remote camera window for an entity.
+cmd-camera-help = Usage: camera
+
+cmd-camera-hint =
+cmd-camera-wrong-argument = Argument must be a valid netUid or a player name.
diff --git a/Resources/Locale/en-US/administration/ui/admin-camera-window.ftl b/Resources/Locale/en-US/administration/ui/admin-camera-window.ftl
new file mode 100644
index 0000000000..bf9f45a745
--- /dev/null
+++ b/Resources/Locale/en-US/administration/ui/admin-camera-window.ftl
@@ -0,0 +1,5 @@
+admin-camera-window-title = Observing { $name }
+admin-camera-window-title-placeholder = Observing
+admin-camera-window-follow = Follow
+admin-camera-window-pop-out = Pop out
+admin-camera-window-pop-in = Pop in
diff --git a/Resources/Locale/en-US/administration/ui/player-panel.ftl b/Resources/Locale/en-US/administration/ui/player-panel.ftl
index f2d89fe2c2..7b3571e380 100644
--- a/Resources/Locale/en-US/administration/ui/player-panel.ftl
+++ b/Resources/Locale/en-US/administration/ui/player-panel.ftl
@@ -22,3 +22,4 @@ player-panel-rejuvenate = Rejuvenate
player-panel-false = False
player-panel-true = True
player-panel-follow = Follow
+player-panel-camera = Camera
diff --git a/Resources/Locale/en-US/killtome.ftl b/Resources/Locale/en-US/killtome.ftl
new file mode 100644
index 0000000000..2a45db41ff
--- /dev/null
+++ b/Resources/Locale/en-US/killtome.ftl
@@ -0,0 +1,11 @@
+killtome-rules =
+ Kill Tome Rules:
+ 1. The humanoid whose name is written in this note shall die.
+ 2. If the name is shared by multiple humanoids, a random humanoid with that name will die.
+ 3. Each name shall be written on a new line.
+ 4. Names must be written in the format: "Name, Delay (in seconds)" (e.g., John Doe, 40).
+ 5. A humanoid can be killed by the same Kill Tome only once.
+
+killtome-kill-success = The name is written. The countdown begins.
+killtome-death = You feel sudden pain in your chest!
+killtome-death-others = {CAPITALIZE($target)} grabs onto {POSS-ADJ($target)} chest and falls to the ground!
diff --git a/Resources/Prototypes/Entities/Interface/admin_tools.yml b/Resources/Prototypes/Entities/Interface/admin_tools.yml
new file mode 100644
index 0000000000..a8c978ed0c
--- /dev/null
+++ b/Resources/Prototypes/Entities/Interface/admin_tools.yml
@@ -0,0 +1,14 @@
+# dummy entity for the admin camera EUI
+# this gets parented to the person being observed
+
+- type: entity
+ id: AdminCamera
+ categories: [ HideSpawnMenu ]
+ name: admin camera
+ description: We are watching you.
+ components:
+ - type: Visibility
+ layer: 8 # Don't network this to anyone so cheat clients can't see it. We are adding a PVS override to the user.
+ - type: Eye # for the camera itself
+ drawFov: false
+ pvsScale: 0.8 # we don't need the full range
diff --git a/Resources/Prototypes/Entities/Objects/Misc/killtome.yml b/Resources/Prototypes/Entities/Objects/Misc/killtome.yml
new file mode 100644
index 0000000000..41339a9281
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Misc/killtome.yml
@@ -0,0 +1,24 @@
+- type: entity
+ name: black tome
+ parent: BasePaper
+ id: KillTome
+ suffix: KillTome, Admeme # To stay true to the lore, please never make this accessible outside of divine intervention (admeme).
+ description: A worn black tome. It smells like old paper.
+ components:
+ - type: Sprite
+ sprite: Objects/Misc/killtome.rsi
+ state: icon
+ - type: KillTome
+ defaultKillDelay: 40
+ damage:
+ types:
+ Blunt: 200
+ - type: Paper
+ content: killtome-rules
+ - type: ActivatableUI
+ key: enum.PaperUiKey.Key
+ requiresComplex: false
+ - type: UserInterface
+ interfaces:
+ enum.PaperUiKey.Key:
+ type: PaperBoundUserInterface
diff --git a/Resources/Prototypes/Recipes/Reactions/chemicals.yml b/Resources/Prototypes/Recipes/Reactions/chemicals.yml
index cdd5659c7e..959488376c 100644
--- a/Resources/Prototypes/Recipes/Reactions/chemicals.yml
+++ b/Resources/Prototypes/Recipes/Reactions/chemicals.yml
@@ -104,6 +104,7 @@
id: PotassiumExplosion
impact: High
priority: 20
+ conserveEnergy: false
reactants:
Water:
amount: 1
@@ -112,15 +113,16 @@
effects:
- !type:ExplosionReactionEffect
explosionType: Default
- maxIntensity: 100
- intensityPerUnit: 0.5 # 50+50 reagent for maximum explosion
- intensitySlope: 4
+ intensityPerUnit: 0.25
maxTotalIntensity: 100
+ intensitySlope: 4
+ maxIntensity: 7
- type: reaction
id: Smoke
- priority: 10
+ priority: -10
impact: High
+ conserveEnergy: false
reactants:
Phosphorus:
amount: 1
@@ -137,8 +139,9 @@
- type: reaction
id: Foam
- priority: 10
+ priority: -10
impact: High
+ conserveEnergy: false
reactants:
Fluorosurfactant:
amount: 1
@@ -154,7 +157,8 @@
- type: reaction
id: IronMetalFoam
impact: High
- priority: 10
+ priority: -10
+ conserveEnergy: false
reactants:
Iron:
amount: 3
@@ -172,7 +176,8 @@
- type: reaction
id: AluminiumMetalFoam
impact: High
- priority: 10
+ priority: -10
+ conserveEnergy: false
reactants:
Aluminium:
amount: 3
@@ -191,6 +196,7 @@
id: UraniumEmpExplosion
impact: High
priority: 20
+ conserveEnergy: false
reactants:
Iron:
amount: 1
@@ -209,6 +215,7 @@
id: Flash
impact: High
priority: 20
+ conserveEnergy: false
reactants:
Aluminium:
amount: 1
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/meta.json b/Resources/Textures/Objects/Devices/pinpointer.rsi/meta.json
index c6e79f0c9b..fe592577c4 100644
--- a/Resources/Textures/Objects/Devices/pinpointer.rsi/meta.json
+++ b/Resources/Textures/Objects/Devices/pinpointer.rsi/meta.json
@@ -5,7 +5,7 @@
"y": 32
},
"license": "CC-BY-SA-3.0",
- "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/59f2a4e10e5ba36033c9734ddebfbbdc6157472d, station and syndicate pinpointer resprited by Ubaser, inhands by TiniestShark (github)",
+ "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/59f2a4e10e5ba36033c9734ddebfbbdc6157472d, station and syndicate pinpointer resprited by Ubaser, inhands by TiniestShark (github). Modified by @whatston3 and @Quantum-cross (github) to center the sprite so arrow rotation is around the proper axis.",
"states": [
{
"name": "pinonalert",
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalert.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalert.png
index 39e73ee266..28c34227a3 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalert.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalert.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalertdirect.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalertdirect.png
index 6e53ef3a3b..90399cabd2 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalertdirect.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalertdirect.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalertnull.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalertnull.png
index 1556d82c76..7e959c0155 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalertnull.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonalertnull.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonclose.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonclose.png
index d652e4cd02..31c8baba2c 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonclose.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonclose.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirect.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirect.png
index c2ac6e44e2..65ae634ac5 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirect.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirect.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectlarge.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectlarge.png
index c8b1fa4875..d7e86873b7 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectlarge.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectlarge.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectsmall.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectsmall.png
index ab7940efd9..bef7821c7c 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectsmall.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectsmall.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectxtrlarge.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectxtrlarge.png
index 5db693402c..edbb1dfbcb 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectxtrlarge.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinondirectxtrlarge.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonfar.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonfar.png
index fd0fbf51ac..37ed33b205 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonfar.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonfar.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonmedium.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonmedium.png
index 5f8e89aec2..cb5940779e 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonmedium.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonmedium.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonnull.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonnull.png
index a309d053e5..668c8fa9b8 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonnull.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinonnull.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-crew.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-crew.png
index af24601880..26b5c3b3dc 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-crew.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-crew.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-crewprox.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-crewprox.png
index c147288085..4424d1aba7 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-crewprox.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-crewprox.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-station.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-station.png
index 2b931884db..d1f10db2dd 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-station.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-station.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-syndicate.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-syndicate.png
index 66c0b9dec0..f1d53a8859 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-syndicate.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-syndicate.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-way.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-way.png
index 478750439f..b11dcbff9c 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-way.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer-way.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer.png
index 24c7e0dc68..86fb1f2380 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer.png differ
diff --git a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer_thief.png b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer_thief.png
index 745d26ac49..5e62b9ef8d 100644
Binary files a/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer_thief.png and b/Resources/Textures/Objects/Devices/pinpointer.rsi/pinpointer_thief.png differ
diff --git a/Resources/Textures/Objects/Misc/killtome.rsi/icon.png b/Resources/Textures/Objects/Misc/killtome.rsi/icon.png
new file mode 100644
index 0000000000..b3f5427ebe
Binary files /dev/null and b/Resources/Textures/Objects/Misc/killtome.rsi/icon.png differ
diff --git a/Resources/Textures/Objects/Misc/killtome.rsi/meta.json b/Resources/Textures/Objects/Misc/killtome.rsi/meta.json
new file mode 100644
index 0000000000..b418b2056f
--- /dev/null
+++ b/Resources/Textures/Objects/Misc/killtome.rsi/meta.json
@@ -0,0 +1,14 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "alexmactep",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "icon"
+ }
+ ]
+}