diff --git a/Content.Client/TapeRecorder/TapeRecorderSystem.cs b/Content.Client/TapeRecorder/TapeRecorderSystem.cs
new file mode 100644
index 00000000000..c228ea356c2
--- /dev/null
+++ b/Content.Client/TapeRecorder/TapeRecorderSystem.cs
@@ -0,0 +1,24 @@
+using Content.Shared.TapeRecorder;
+
+namespace Content.Client.TapeRecorder;
+
+///
+/// Required for client side prediction stuff
+///
+public sealed class TapeRecorderSystem : SharedTapeRecorderSystem
+{
+ private TimeSpan _lastTickTime = TimeSpan.Zero;
+
+ public override void Update(float frameTime)
+ {
+ if (!Timing.IsFirstTimePredicted)
+ return;
+
+ //We need to know the exact time period that has passed since the last update to ensure the tape position is sync'd with the server
+ //Since the client can skip frames when lagging, we cannot use frameTime
+ var realTime = (float) (Timing.CurTime - _lastTickTime).TotalSeconds;
+ _lastTickTime = Timing.CurTime;
+
+ base.Update(realTime);
+ }
+}
\ No newline at end of file
diff --git a/Content.Client/TapeRecorder/Ui/TapeRecorderBoundUserInterface.cs b/Content.Client/TapeRecorder/Ui/TapeRecorderBoundUserInterface.cs
new file mode 100644
index 00000000000..33df6267480
--- /dev/null
+++ b/Content.Client/TapeRecorder/Ui/TapeRecorderBoundUserInterface.cs
@@ -0,0 +1,64 @@
+using Content.Shared.TapeRecorder.Components;
+using Content.Shared.TapeRecorder.Events;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+
+namespace Content.Client.TapeRecorder.Ui;
+
+public sealed class TapeRecorderBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
+{
+ [Dependency] private readonly IEntityManager _entMan = default!;
+
+ [ViewVariables]
+ private TapeRecorderWindow? _window;
+
+ [ViewVariables]
+ private TimeSpan _printCooldown;
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = new(_entMan, Owner);
+ _window.OnClose += Close;
+ _window.OnModeChanged += ChangeMode;
+ _window.OnPrintTranscript += PrintTranscript;
+ _window.OpenCentered();
+ }
+
+ private void ChangeMode(TapeRecorderMode mode)
+ {
+ SendMessage(new ChangeModeTapeRecorderMessage(mode));
+ }
+
+ private void PrintTranscript()
+ {
+ SendMessage(new PrintTapeRecorderMessage());
+
+ _window?.UpdatePrint(true);
+
+ Timer.Spawn(_printCooldown, () =>
+ {
+ _window?.UpdatePrint(false);
+ });
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (state is not TapeRecorderState cast)
+ return;
+
+ _printCooldown = cast.PrintCooldown;
+
+ _window?.UpdateState(cast);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing)
+ _window?.Dispose();
+ }
+}
diff --git a/Content.Client/TapeRecorder/Ui/TapeRecorderWindow.xaml b/Content.Client/TapeRecorder/Ui/TapeRecorderWindow.xaml
new file mode 100644
index 00000000000..0e22a7815fe
--- /dev/null
+++ b/Content.Client/TapeRecorder/Ui/TapeRecorderWindow.xaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Content.Client/TapeRecorder/Ui/TapeRecorderWindow.xaml.cs b/Content.Client/TapeRecorder/Ui/TapeRecorderWindow.xaml.cs
new file mode 100644
index 00000000000..e387261d58b
--- /dev/null
+++ b/Content.Client/TapeRecorder/Ui/TapeRecorderWindow.xaml.cs
@@ -0,0 +1,133 @@
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Shuttles.Components;
+using Content.Shared.TapeRecorder.Components;
+using Content.Shared.TapeRecorder.Events;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+
+namespace Content.Client.TapeRecorder.Ui;
+
+[GenerateTypedNameReferences]
+public sealed partial class TapeRecorderWindow : FancyWindow
+{
+ private IEntityManager _entMan;
+
+ private EntityUid _owner;
+ private bool _onCooldown;
+ private bool _hasCasette;
+ private TapeRecorderMode _mode = TapeRecorderMode.Stopped;
+
+ private RadioOptions _options = default!;
+ private bool _updating;
+
+ public Action? OnModeChanged;
+ public Action? OnPrintTranscript;
+
+ public TapeRecorderWindow(IEntityManager entMan, EntityUid owner)
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ _entMan = entMan;
+
+ _owner = owner;
+
+ _options = new RadioOptions(RadioOptionsLayout.Horizontal);
+ Buttons.AddChild(_options);
+ _options.FirstButtonStyle = "OpenRight";
+ _options.LastButtonStyle = "OpenLeft";
+ _options.ButtonStyle = "OpenBoth";
+ foreach (var mode in Enum.GetValues())
+ {
+ var name = mode.ToString().ToLower();
+ _options.AddItem(Loc.GetString($"tape-recorder-menu-{name}-button"), mode);
+ }
+
+ _options.OnItemSelected += args =>
+ {
+ if (_updating) // don't tell server to change mode to the mode it told us
+ return;
+ args.Button.Select(args.Id);
+ var mode = args.Button.SelectedValue;
+ OnModeChanged?.Invoke(mode);
+ };
+
+ PrintButton.OnPressed += _ => OnPrintTranscript?.Invoke();
+
+ SetEnabled(TapeRecorderMode.Recording, false);
+ SetEnabled(TapeRecorderMode.Playing, false);
+ SetEnabled(TapeRecorderMode.Rewinding, false);
+ }
+
+ private void SetSlider(float maxTime, float currentTime)
+ {
+ PlaybackSlider.Disabled = true;
+ PlaybackSlider.MaxValue = maxTime;
+ PlaybackSlider.Value = currentTime;
+ }
+
+ public void UpdatePrint(bool disabled)
+ {
+ PrintButton.Disabled = disabled;
+ _onCooldown = disabled;
+ }
+
+ public void UpdateState(TapeRecorderState state)
+ {
+ if (!_entMan.TryGetComponent(_owner, out var comp))
+ return;
+
+ _mode = comp.Mode; // TODO: update UI on handling state instead of adding UpdateUI to everything
+ _hasCasette = state.HasCasette;
+
+ _updating = true;
+
+ CassetteLabel.Text = _hasCasette
+ ? Loc.GetString("tape-recorder-menu-cassette-label", ("cassetteName", state.CassetteName))
+ : Loc.GetString("tape-recorder-menu-no-cassette-label");
+
+ // Select the currently used mode
+ _options.SelectByValue(_mode);
+
+ // When tape is ejected or a button can't be used, disable it
+ // Server will change to paused once a tape is inactive
+ var tapeLeft = state.CurrentTime < state.MaxTime;
+ SetEnabled(TapeRecorderMode.Recording, tapeLeft);
+ SetEnabled(TapeRecorderMode.Playing, tapeLeft);
+ SetEnabled(TapeRecorderMode.Rewinding, state.CurrentTime > float.Epsilon);
+
+ if (state.HasCasette)
+ SetSlider(state.MaxTime, state.CurrentTime);
+
+ _updating = false;
+ }
+
+ private void SetEnabled(TapeRecorderMode mode, bool condition)
+ {
+ _options.SetItemDisabled((int) mode, !(_hasCasette && condition));
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ if (!_entMan.HasComponent(_owner))
+ return;
+
+ if (!_entMan.TryGetComponent(_owner, out var comp))
+ return;
+
+ if (_mode != comp.Mode)
+ {
+ _mode = comp.Mode;
+ _options.SelectByValue(_mode);
+ }
+
+ var speed = _mode == TapeRecorderMode.Rewinding
+ ? -comp.RewindSpeed
+ : 1f;
+ PlaybackSlider.Value += args.DeltaSeconds * speed;
+ }
+}
\ No newline at end of file
diff --git a/Content.Server/TapeRecorder/TapeRecorderSystem.cs b/Content.Server/TapeRecorder/TapeRecorderSystem.cs
new file mode 100644
index 00000000000..f6e8b7ee77d
--- /dev/null
+++ b/Content.Server/TapeRecorder/TapeRecorderSystem.cs
@@ -0,0 +1,132 @@
+using Content.Server.Chat.Systems;
+using Content.Server.Hands.Systems;
+using Content.Server.Speech;
+using Content.Server.Speech.Components;
+using Content.Shared.Chat;
+using Content.Shared.Paper;
+using Content.Shared.Speech;
+using Content.Shared.TapeRecorder;
+using Content.Shared.TapeRecorder.Components;
+using Content.Shared.TapeRecorder.Events;
+using Robust.Server.Audio;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using System.Text;
+
+namespace Content.Server.TapeRecorder;
+
+public sealed class TapeRecorderSystem : SharedTapeRecorderSystem
+{
+ [Dependency] private readonly ChatSystem _chat = default!;
+ [Dependency] private readonly HandsSystem _hands = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly PaperSystem _paper = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnListen);
+ SubscribeLocalEvent(OnPrintMessage);
+ }
+
+ ///
+ /// Given a time range, play all messages on a tape within said range, [start, end).
+ /// Split into this system as shared does not have ChatSystem access
+ ///
+ protected override void ReplayMessagesInSegment(Entity ent, TapeCassetteComponent tape, float segmentStart, float segmentEnd)
+ {
+ var voice = EnsureComp(ent);
+ var speech = EnsureComp(ent);
+
+ foreach (var message in tape.RecordedData)
+ {
+ if (message.Timestamp < tape.CurrentPosition || message.Timestamp >= segmentEnd)
+ continue;
+
+ //Change the voice to match the speaker
+ voice.NameOverride = message.Name ?? ent.Comp.DefaultName;
+ // TODO: mimic the exact string chosen when the message was recorded
+ var verb = message.Verb ?? SharedChatSystem.DefaultSpeechVerb;
+ speech.SpeechVerb = _proto.Index(verb);
+ //Play the message
+ _chat.TrySendInGameICMessage(ent, message.Message, InGameICChatType.Speak, false);
+ }
+ }
+
+ ///
+ /// Whenever someone speaks within listening range, record it to tape
+ ///
+ private void OnListen(Entity ent, ref ListenEvent args)
+ {
+ // mode should never be set when it isn't active but whatever
+ if (ent.Comp.Mode != TapeRecorderMode.Recording || !HasComp(ent))
+ return;
+
+ // No feedback loops
+ if (args.Source == ent.Owner)
+ return;
+
+ if (!TryGetTapeCassette(ent, out var cassette))
+ return;
+
+ // TODO: Handle "Someone" when whispering from far away, needs chat refactor
+
+ //Handle someone using a voice changer
+ var nameEv = new TransformSpeakerNameEvent(args.Source, Name(args.Source));
+ RaiseLocalEvent(args.Source, nameEv);
+
+ //Add a new entry to the tape
+ var verb = _chat.GetSpeechVerb(args.Source, args.Message);
+ var name = nameEv.VoiceName;
+ cassette.Comp.Buffer.Add(new TapeCassetteRecordedMessage(cassette.Comp.CurrentPosition, name, verb, args.Message));
+ }
+
+ private void OnPrintMessage(Entity ent, ref PrintTapeRecorderMessage args)
+ {
+ var (uid, comp) = ent;
+
+ if (comp.CooldownEndTime > Timing.CurTime)
+ return;
+
+ if (!TryGetTapeCassette(ent, out var cassette))
+ return;
+
+ var text = new StringBuilder();
+ var paper = Spawn(comp.PaperPrototype, Transform(ent).Coordinates);
+
+ // Sorting list by time for overwrite order
+ // TODO: why is this needed? why wouldn't it be stored in order
+ var data = cassette.Comp.RecordedData;
+ data.Sort((x,y) => x.Timestamp.CompareTo(y.Timestamp));
+
+ // Looking if player's entity exists to give paper in its hand
+ var player = args.Actor;
+ if (Exists(player))
+ _hands.PickupOrDrop(player, paper, checkActionBlocker: false);
+
+ if (!TryComp(paper, out var paperComp))
+ return;
+
+ Audio.PlayPvs(comp.PrintSound, ent);
+
+ text.AppendLine(Loc.GetString("tape-recorder-print-start-text"));
+ text.AppendLine();
+ foreach (var message in cassette.Comp.RecordedData)
+ {
+ var name = message.Name ?? ent.Comp.DefaultName;
+ var time = TimeSpan.FromSeconds((double) message.Timestamp);
+
+ text.AppendLine(Loc.GetString("tape-recorder-print-message-text",
+ ("time", time.ToString(@"hh\:mm\:ss")),
+ ("source", name),
+ ("message", message.Message)));
+ }
+ text.AppendLine();
+ text.Append(Loc.GetString("tape-recorder-print-end-text"));
+
+ _paper.SetContent((paper, paperComp), text.ToString());
+
+ comp.CooldownEndTime = Timing.CurTime + comp.PrintCooldown;
+ }
+}
diff --git a/Content.Shared/TapeRecorder/Components/ActiveTapeRecorderComponent.cs b/Content.Shared/TapeRecorder/Components/ActiveTapeRecorderComponent.cs
new file mode 100644
index 00000000000..24f0f9d9249
--- /dev/null
+++ b/Content.Shared/TapeRecorder/Components/ActiveTapeRecorderComponent.cs
@@ -0,0 +1,9 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.TapeRecorder.Components;
+
+///
+/// Added to tape records that are updating, winding or rewinding the tape.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class ActiveTapeRecorderComponent : Component;
\ No newline at end of file
diff --git a/Content.Shared/TapeRecorder/Components/TapeCassetteComponent.cs b/Content.Shared/TapeRecorder/Components/TapeCassetteComponent.cs
new file mode 100644
index 00000000000..9ff69db9026
--- /dev/null
+++ b/Content.Shared/TapeRecorder/Components/TapeCassetteComponent.cs
@@ -0,0 +1,72 @@
+using Content.Shared.Whitelist;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.TapeRecorder.Components;
+
+// TODO: add things client needs for ui to networked state
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedTapeRecorderSystem))]
+[AutoGenerateComponentState]
+public sealed partial class TapeCassetteComponent : Component
+{
+ ///
+ /// A list of all recorded voice, containing timestamp, name and spoken words
+ ///
+ [DataField]
+ public List RecordedData = new();
+
+ ///
+ /// The current position within the tape we are at, in seconds
+ /// Only dirtied when the tape recorder is stopped
+ ///
+ [DataField, AutoNetworkedField]
+ public float CurrentPosition = 0f;
+
+ ///
+ /// Maximum capacity of this tape
+ ///
+ [DataField]
+ public TimeSpan MaxCapacity = TimeSpan.FromSeconds(120);
+
+ ///
+ /// How long to spool the tape after it was damaged
+ ///
+ [DataField]
+ public TimeSpan RepairDelay = TimeSpan.FromSeconds(3);
+
+ ///
+ /// When an entry is damaged, the chance of each character being corrupted.
+ ///
+ [DataField]
+ public float CorruptionChance = 0.25f;
+
+ //Locale references
+ [DataField]
+ public LocId TextUnintelligable = "tape-recorder-voice-unintelligible";
+
+ [DataField]
+ public LocId TextCorruptionCharacter = "tape-recorder-message-corruption";
+
+ [DataField]
+ public LocId TextExamine = "tape-cassette-position";
+
+ [DataField]
+ public LocId TextDamaged = "tape-cassette-damaged";
+
+ ///
+ /// Temporary storage for all heard messages that need processing
+ ///
+ [DataField]
+ public List Buffer = new();
+
+ ///
+ /// Whitelist for tools that can be used to respool a damaged tape.
+ ///
+ [DataField(required: true)]
+ public EntityWhitelist RepairWhitelist = new();
+}
+
+///
+/// Removed from the cassette when damaged to prevent it being played until repaired
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class FitsInTapeRecorderComponent : Component;
\ No newline at end of file
diff --git a/Content.Shared/TapeRecorder/Components/TapeRecorderComponent.cs b/Content.Shared/TapeRecorder/Components/TapeRecorderComponent.cs
new file mode 100644
index 00000000000..46b9eab89a4
--- /dev/null
+++ b/Content.Shared/TapeRecorder/Components/TapeRecorderComponent.cs
@@ -0,0 +1,124 @@
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.TapeRecorder.Components;
+
+[RegisterComponent, NetworkedComponent, Access(typeof(SharedTapeRecorderSystem))]
+[AutoGenerateComponentState, AutoGenerateComponentPause]
+public sealed partial class TapeRecorderComponent : Component
+{
+ ///
+ /// The current tape recorder mode, controls what using the item will do
+ ///
+ [DataField, AutoNetworkedField]
+ public TapeRecorderMode Mode = TapeRecorderMode.Stopped;
+
+ ///
+ /// Paper that will spawn when printing transcript
+ ///
+ [DataField]
+ public EntProtoId PaperPrototype = "TapeRecorderTranscript";
+
+ ///
+ /// How fast can this tape recorder rewind
+ /// Acts as a multiplier for the frameTime
+ ///
+ [DataField]
+ public float RewindSpeed = 3f;
+
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoPausedField]
+ public TimeSpan CooldownEndTime = TimeSpan.Zero;
+
+ ///
+ /// Cooldown of print button
+ ///
+ [DataField]
+ public TimeSpan PrintCooldown = TimeSpan.FromSeconds(4);
+
+ ///
+ /// Default name as fallback if a message doesn't have one.
+ ///
+ [DataField]
+ public LocId DefaultName = "tape-recorder-voice-unknown";
+
+ ///
+ /// Sound on print transcript
+ ///
+ [DataField]
+ public SoundSpecifier PrintSound = new SoundPathSpecifier("/Audio/Machines/diagnoser_printing.ogg")
+ {
+ Params = AudioParams.Default.WithVolume(-2f).WithMaxDistance(3f)
+ };
+
+ ///
+ /// What sound is used when play mode is activated
+ ///
+ [DataField]
+ public SoundSpecifier PlaySound = new SoundPathSpecifier("/Audio/Items/Taperecorder/taperecorder_play.ogg")
+ {
+ Params = AudioParams.Default.WithVolume(-2f).WithMaxDistance(3f)
+ };
+
+ ///
+ /// What sound is used when stop mode is activated
+ ///
+ [DataField]
+ public SoundSpecifier StopSound = new SoundPathSpecifier("/Audio/Items/Taperecorder/taperecorder_stop.ogg")
+ {
+ Params = AudioParams.Default.WithVolume(-2f).WithMaxDistance(3f)
+ };
+
+ ///
+ /// What sound is used when rewind mode is activated
+ ///
+ [DataField]
+ public SoundSpecifier RewindSound = new SoundPathSpecifier("/Audio/Items/Taperecorder/taperecorder_rewind.ogg")
+ {
+ Params = AudioParams.Default.WithVolume(-2f).WithMaxDistance(3f)
+ };
+
+ //Locale references
+ [DataField]
+ public LocId TextCantEject = "tape-recorder-locked";
+
+ [DataField]
+ public LocId TextModePlaying = "tape-recorder-playing";
+
+ [DataField]
+ public LocId TextModeRecording = "tape-recorder-recording";
+
+ [DataField]
+ public LocId TextModeRewinding = "tape-recorder-rewinding";
+
+ [DataField]
+ public LocId TextModeStopped = "tape-recorder-stopped";
+
+ [DataField]
+ public LocId TextModeEmpty = "tape-recorder-empty";
+}
+
+[Serializable, NetSerializable]
+public enum TapeRecorderVisuals : byte
+{
+ Mode,
+ TapeInserted
+}
+
+[Serializable, NetSerializable]
+public enum TapeRecorderMode : byte
+{
+ Stopped,
+ Recording,
+ Playing,
+ Rewinding
+}
+
+[Serializable, NetSerializable]
+public enum TapeRecorderUIKey : byte
+{
+ Key
+}
\ No newline at end of file
diff --git a/Content.Shared/TapeRecorder/Events/TapeRecorderEvents.cs b/Content.Shared/TapeRecorder/Events/TapeRecorderEvents.cs
new file mode 100644
index 00000000000..dfd5edcaf31
--- /dev/null
+++ b/Content.Shared/TapeRecorder/Events/TapeRecorderEvents.cs
@@ -0,0 +1,50 @@
+using Content.Shared.DoAfter;
+using Content.Shared.TapeRecorder.Components;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.TapeRecorder.Events;
+
+[Serializable, NetSerializable]
+public sealed partial class TapeCassetteRepairDoAfterEvent : SimpleDoAfterEvent;
+
+[Serializable, NetSerializable]
+public sealed class ChangeModeTapeRecorderMessage : BoundUserInterfaceMessage
+{
+ public TapeRecorderMode Mode;
+
+ public ChangeModeTapeRecorderMessage(TapeRecorderMode mode)
+ {
+ Mode = mode;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class PrintTapeRecorderMessage : BoundUserInterfaceMessage;
+
+[Serializable, NetSerializable]
+public sealed class TapeRecorderState : BoundUserInterfaceState
+{
+ // TODO: check the itemslot on client instead of putting easy casette stuff in the state
+ public bool HasCasette;
+ public bool HasData;
+ public float CurrentTime;
+ public float MaxTime;
+ public string CassetteName;
+ public TimeSpan PrintCooldown;
+
+ public TapeRecorderState(
+ bool hasCasette,
+ bool hasData,
+ float currentTime,
+ float maxTime,
+ string cassetteName,
+ TimeSpan printCooldown)
+ {
+ HasCasette = hasCasette;
+ HasData = hasData;
+ CurrentTime = currentTime;
+ MaxTime = maxTime;
+ CassetteName = cassetteName;
+ PrintCooldown = printCooldown;
+ }
+}
diff --git a/Content.Shared/TapeRecorder/SharedTapeRecorderSystem.cs b/Content.Shared/TapeRecorder/SharedTapeRecorderSystem.cs
new file mode 100644
index 00000000000..b5b8fb25872
--- /dev/null
+++ b/Content.Shared/TapeRecorder/SharedTapeRecorderSystem.cs
@@ -0,0 +1,422 @@
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.Damage;
+using Content.Shared.Destructible;
+using Content.Shared.DoAfter;
+using Content.Shared.Examine;
+using Content.Shared.Interaction;
+using Content.Shared.Labels.Components;
+using Content.Shared.Popups;
+using Content.Shared.Tag;
+using Content.Shared.TapeRecorder.Components;
+using Content.Shared.TapeRecorder.Events;
+using Content.Shared.Toggleable;
+using Content.Shared.UserInterface;
+using Content.Shared.Whitelist;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Containers;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+
+namespace Content.Shared.TapeRecorder;
+
+public abstract class SharedTapeRecorderSystem : EntitySystem
+{
+ [Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
+ [Dependency] protected readonly IGameTiming Timing = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] protected readonly SharedAudioSystem Audio = default!;
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] private readonly ItemSlotsSystem _slots = default!;
+ [Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly SharedUserInterfaceSystem _ui = default!;
+
+ protected const string SlotName = "cassette_tape";
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnCassetteRemoveAttempt);
+ SubscribeLocalEvent(OnCassetteRemoved);
+ SubscribeLocalEvent(OnCassetteInserted);
+ SubscribeLocalEvent(OnRecorderExamined);
+ SubscribeLocalEvent(OnChangeModeMessage);
+ SubscribeLocalEvent(OnUIOpened);
+
+ SubscribeLocalEvent(OnTapeExamined);
+ SubscribeLocalEvent(OnDamagedChanged);
+ SubscribeLocalEvent(OnInteractingWithCassette);
+ SubscribeLocalEvent(OnTapeCassetteRepair);
+ }
+
+ ///
+ /// Process active tape recorder modes
+ ///
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out _, out var comp))
+ {
+ var ent = (uid, comp);
+ if (!TryGetTapeCassette(uid, out var tape))
+ {
+ SetMode(ent, TapeRecorderMode.Stopped);
+ continue;
+ }
+
+ var continuing = comp.Mode switch
+ {
+ TapeRecorderMode.Recording => ProcessRecordingTapeRecorder(ent, frameTime),
+ TapeRecorderMode.Playing => ProcessPlayingTapeRecorder(ent, frameTime),
+ TapeRecorderMode.Rewinding => ProcessRewindingTapeRecorder(ent, frameTime),
+ _ => false
+ };
+
+ if (continuing)
+ continue;
+
+ SetMode(ent, TapeRecorderMode.Stopped);
+ Dirty(tape); // make sure clients have the right value once it's stopped
+ }
+ }
+
+ private void OnUIOpened(Entity ent, ref AfterActivatableUIOpenEvent args)
+ {
+ UpdateUI(ent);
+ }
+
+ ///
+ /// UI message when choosing between recorder modes
+ ///
+ private void OnChangeModeMessage(Entity ent, ref ChangeModeTapeRecorderMessage args)
+ {
+ SetMode(ent, args.Mode);
+ }
+
+ ///
+ /// Update the tape position and overwrite any messages between the previous and new position
+ ///
+ /// The tape recorder to process
+ /// Number of seconds that have passed since the last call
+ /// True if the tape recorder should continue in the current mode, False if it should switch to the Stopped mode
+ private bool ProcessRecordingTapeRecorder(Entity ent, float frameTime)
+ {
+ if (!TryGetTapeCassette(ent, out var tape))
+ return false;
+
+ var currentTime = tape.Comp.CurrentPosition + frameTime;
+
+ //'Flushed' in this context is a mark indicating the message was not added between the last update and this update
+ //Remove any flushed messages in the segment we just recorded over (ie old messages)
+ tape.Comp.RecordedData.RemoveAll(x => x.Timestamp > tape.Comp.CurrentPosition && x.Timestamp <= currentTime);
+
+ tape.Comp.RecordedData.AddRange(tape.Comp.Buffer);
+
+ tape.Comp.Buffer.Clear();
+
+ //Update the tape's current time
+ tape.Comp.CurrentPosition = (float) Math.Min(currentTime, tape.Comp.MaxCapacity.TotalSeconds);
+
+ //If we have reached the end of the tape - stop
+ return tape.Comp.CurrentPosition < tape.Comp.MaxCapacity.TotalSeconds;
+ }
+
+ ///
+ /// Update the tape position and play any messages with timestamps between the previous and new position
+ ///
+ /// The tape recorder to process
+ /// Number of seconds that have passed since the last call
+ /// True if the tape recorder should continue in the current mode, False if it should switch to the Stopped mode
+ private bool ProcessPlayingTapeRecorder(Entity ent, float frameTime)
+ {
+ if (!TryGetTapeCassette(ent, out var tape))
+ return false;
+
+ //Get the segment of the tape to be played
+ //And any messages within that time period
+ var currentTime = tape.Comp.CurrentPosition + frameTime;
+
+ ReplayMessagesInSegment(ent, tape.Comp, tape.Comp.CurrentPosition, currentTime);
+
+ //Update the tape's position
+ tape.Comp.CurrentPosition = (float) Math.Min(currentTime, tape.Comp.MaxCapacity.TotalSeconds);
+
+ //Stop when we reach the end of the tape
+ return tape.Comp.CurrentPosition < tape.Comp.MaxCapacity.TotalSeconds;
+ }
+
+ ///
+ /// Update the tape position in reverse
+ ///
+ /// The tape recorder to process
+ /// Number of seconds that have passed since the last call
+ /// True if the tape recorder should continue in the current mode, False if it should switch to the Stopped mode
+ private bool ProcessRewindingTapeRecorder(Entity ent, float frameTime)
+ {
+ if (!TryGetTapeCassette(ent, out var tape))
+ return false;
+
+ //Calculate how far we have rewound
+ var rewindTime = frameTime * ent.Comp.RewindSpeed;
+ //Update the current time, clamp to 0
+ tape.Comp.CurrentPosition = Math.Max(0, tape.Comp.CurrentPosition - rewindTime);
+
+ //If we have reached the beginning of the tape, stop
+ return tape.Comp.CurrentPosition >= float.Epsilon;
+ }
+
+ ///
+ /// Plays messages back on the server.
+ /// Does nothing on the client.
+ ///
+ protected virtual void ReplayMessagesInSegment(Entity ent, TapeCassetteComponent tape, float segmentStart, float segmentEnd)
+ {
+ }
+
+ ///
+ /// Start repairing a damaged tape when using a screwdriver or pen on it
+ ///
+ protected void OnInteractingWithCassette(Entity ent, ref InteractUsingEvent args)
+ {
+ //Is the tape damaged?
+ if (HasComp(ent))
+ return;
+
+ //Are we using a valid repair tool?
+ if (_whitelist.IsWhitelistFail(ent.Comp.RepairWhitelist, args.Used))
+ return;
+
+ _doAfter.TryStartDoAfter(new DoAfterArgs(EntityManager, args.User, ent.Comp.RepairDelay, new TapeCassetteRepairDoAfterEvent(), ent, target: ent, used: args.Used)
+ {
+ BreakOnMove = true,
+ NeedHand = true
+ });
+ }
+
+ ///
+ /// Repair a damaged tape
+ ///
+ protected void OnTapeCassetteRepair(Entity ent, ref TapeCassetteRepairDoAfterEvent args)
+ {
+ if (args.Handled || args.Cancelled || args.Args.Target == null)
+ return;
+
+ //Cant repair if not damaged
+ if (HasComp(ent))
+ return;
+
+ _appearance.SetData(ent, ToggleVisuals.Toggled, false);
+ AddComp(ent);
+ args.Handled = true;
+ }
+
+ ///
+ /// When the cassette has been damaged, corrupt and entry and unspool it
+ ///
+ protected void OnDamagedChanged(Entity ent, ref DamageChangedEvent args)
+ {
+ if (args.DamageDelta == null || args.DamageDelta.GetTotal() < 5)
+ return;
+
+ _appearance.SetData(ent, ToggleVisuals.Toggled, true);
+
+ RemComp(ent);
+ CorruptRandomEntry(ent);
+ }
+
+ protected void OnTapeExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ if (!HasComp(ent))
+ {
+ args.PushMarkup(Loc.GetString(ent.Comp.TextDamaged));
+ return;
+ }
+
+ var positionPercentage = Math.Floor(ent.Comp.CurrentPosition / ent.Comp.MaxCapacity.TotalSeconds * 100);
+ var tapePosMsg = Loc.GetString(ent.Comp.TextExamine, ("position", positionPercentage));
+ args.PushMarkup(tapePosMsg);
+ }
+
+ protected void OnRecorderExamined(Entity ent, ref ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ //Check if we have a tape cassette inserted
+ if (!TryGetTapeCassette(ent, out var tape))
+ {
+ args.PushMarkup(Loc.GetString(ent.Comp.TextModeEmpty));
+ return;
+ }
+
+ args.PushMarkup(Loc.GetString(ent.Comp.Mode switch
+ {
+ TapeRecorderMode.Playing => ent.Comp.TextModePlaying,
+ TapeRecorderMode.Stopped => ent.Comp.TextModeStopped,
+ TapeRecorderMode.Recording => ent.Comp.TextModeRecording,
+ TapeRecorderMode.Rewinding => ent.Comp.TextModeRewinding,
+ _ => ""
+ }));
+
+ OnTapeExamined(tape, ref args);
+ }
+
+ ///
+ /// Prevent removing the tape cassette while the recorder is active
+ ///
+ protected void OnCassetteRemoveAttempt(Entity ent, ref ItemSlotEjectAttemptEvent args)
+ {
+ if (!HasComp(ent))
+ return;
+
+ args.Cancelled = true;
+ }
+
+ protected void OnCassetteRemoved(Entity ent, ref EntRemovedFromContainerMessage args)
+ {
+ SetMode(ent, TapeRecorderMode.Stopped);
+ UpdateAppearance(ent);
+ UpdateUI(ent);
+ }
+
+ protected void OnCassetteInserted(Entity ent, ref EntInsertedIntoContainerMessage args)
+ {
+ UpdateAppearance(ent);
+ UpdateUI(ent);
+ }
+
+ ///
+ /// Update the appearance of the tape recorder.
+ ///
+ /// The tape recorder to update
+ protected void UpdateAppearance(Entity ent)
+ {
+ var hasCassette = TryGetTapeCassette(ent, out _);
+ _appearance.SetData(ent, TapeRecorderVisuals.Mode, ent.Comp.Mode);
+ _appearance.SetData(ent, TapeRecorderVisuals.TapeInserted, hasCassette);
+ }
+
+ ///
+ /// Choose a random recorded entry on the cassette and replace some of the text with hashes
+ ///
+ ///
+ protected void CorruptRandomEntry(TapeCassetteComponent tape)
+ {
+ if (tape.RecordedData.Count == 0)
+ return;
+
+ var entry = _random.Pick(tape.RecordedData);
+
+ var corruption = Loc.GetString(tape.TextCorruptionCharacter);
+
+ var corruptedMessage = new StringBuilder();
+ foreach (var character in entry.Message)
+ {
+ if (_random.Prob(tape.CorruptionChance))
+ corruptedMessage.Append(corruption);
+ else
+ corruptedMessage.Append(character);
+ }
+
+ entry.Name = Loc.GetString(tape.TextUnintelligable);
+ entry.Message = corruptedMessage.ToString();
+ }
+
+ ///
+ /// Set the tape recorder mode and dirty if it is different from the previous mode
+ ///
+ /// The tape recorder to update
+ /// The new mode
+ private void SetMode(Entity ent, TapeRecorderMode mode)
+ {
+ if (mode == ent.Comp.Mode)
+ return;
+
+ if (mode == TapeRecorderMode.Stopped)
+ {
+ RemComp(ent);
+ }
+ else
+ {
+ // can't play without a tape in it...
+ if (!TryGetTapeCassette(ent, out _))
+ return;
+
+ EnsureComp(ent);
+ }
+
+ var sound = ent.Comp.Mode switch
+ {
+ TapeRecorderMode.Stopped => ent.Comp.StopSound,
+ TapeRecorderMode.Rewinding => ent.Comp.RewindSound,
+ _ => ent.Comp.PlaySound
+ };
+ Audio.PlayPvs(sound, ent);
+
+ ent.Comp.Mode = mode;
+ Dirty(ent);
+
+ UpdateUI(ent);
+ }
+
+ protected bool TryGetTapeCassette(EntityUid ent, [NotNullWhen(true)] out Entity tape)
+ {
+ if (_slots.GetItemOrNull(ent, SlotName) is not {} cassette)
+ {
+ tape = default!;
+ return false;
+ }
+
+ if (!TryComp(cassette, out var comp))
+ {
+ tape = default!;
+ return false;
+ }
+
+ tape = new(cassette, comp);
+ return true;
+ }
+
+ private void UpdateUI(Entity ent)
+ {
+ var (uid, comp) = ent;
+ if (!_ui.IsUiOpen(uid, TapeRecorderUIKey.Key))
+ return;
+
+ var hasCassette = TryGetTapeCassette(ent, out var tape);
+ var hasData = false;
+ var currentTime = 0f;
+ var maxTime = 0f;
+ var cassetteName = Loc.GetString("tape-recorder-menu-cassette-unnamed");
+ var cooldown = comp.PrintCooldown;
+
+ if (hasCassette)
+ {
+ hasData = tape.Comp.RecordedData.Count > 0;
+ currentTime = tape.Comp.CurrentPosition;
+ maxTime = (float) tape.Comp.MaxCapacity.TotalSeconds;
+
+ if (TryComp(tape, out var labelComp))
+ if (labelComp.CurrentLabel != null)
+ cassetteName = labelComp.CurrentLabel;
+ }
+
+ var state = new TapeRecorderState(
+ hasCassette,
+ hasData,
+ currentTime,
+ maxTime,
+ cassetteName,
+ cooldown);
+
+ _ui.SetUiState(uid, TapeRecorderUIKey.Key, state);
+ }
+}
diff --git a/Content.Shared/TapeRecorder/TapeCassetteRecordedMessage.cs b/Content.Shared/TapeRecorder/TapeCassetteRecordedMessage.cs
new file mode 100644
index 00000000000..b583939ff6d
--- /dev/null
+++ b/Content.Shared/TapeRecorder/TapeCassetteRecordedMessage.cs
@@ -0,0 +1,51 @@
+using Content.Shared.Speech;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.TapeRecorder;
+
+///
+/// Every chat event recorded on a tape is saved in this format
+///
+[ImplicitDataDefinitionForInheritors]
+public sealed partial class TapeCassetteRecordedMessage : IComparable
+{
+ ///
+ /// Number of seconds since the start of the tape that this event was recorded at
+ ///
+ [DataField(required: true)]
+ public float Timestamp = 0;
+
+ ///
+ /// The name of the entity that spoke
+ ///
+ [DataField]
+ public string? Name;
+
+ ///
+ /// The verb used for this message.
+ ///
+ [DataField]
+ public ProtoId? Verb;
+
+ ///
+ /// What was spoken
+ ///
+ [DataField]
+ public string Message = string.Empty;
+
+ public TapeCassetteRecordedMessage(float timestamp, string name, ProtoId verb, string message)
+ {
+ Timestamp = timestamp;
+ Name = name;
+ Verb = verb;
+ Message = message;
+ }
+
+ public int CompareTo(TapeCassetteRecordedMessage? other)
+ {
+ if (other == null)
+ return 0;
+
+ return (int) (Timestamp - other.Timestamp);
+ }
+}
diff --git a/Resources/Audio/Items/Taperecorder/attributions.yml b/Resources/Audio/Items/Taperecorder/attributions.yml
new file mode 100644
index 00000000000..3a72021c08f
--- /dev/null
+++ b/Resources/Audio/Items/Taperecorder/attributions.yml
@@ -0,0 +1,17 @@
+- files:
+ - "taperecorder_play.ogg"
+ license: "CC0-1.0"
+ copyright: "Taken from cassette tape deck open, close +tape handling.aif by kyles. Converted from Aiff to Ogg."
+ source: "https://freesound.org/people/kyles/sounds/450525/"
+
+- files:
+ - "taperecorder_stop.ogg"
+ license: "CC-BY-4.0"
+ copyright: "Taken from Pressing Stop on An Old Tape Machine by djlprojects. Converted from Mp3 to Ogg."
+ source: "https://freesound.org/people/djlprojects/sounds/392889/"
+
+- files:
+ - "taperecorder_rewind.ogg"
+ license: "CC-BY-NC-4.0"
+ copyright: "Taken from CassetteRewind.flac by acclivity. Converted from Flac to Ogg."
+ source: "https://freesound.org/people/acclivity/sounds/23393/"
\ No newline at end of file
diff --git a/Resources/Audio/Items/Taperecorder/taperecorder_play.ogg b/Resources/Audio/Items/Taperecorder/taperecorder_play.ogg
new file mode 100644
index 00000000000..1bf4d7a3bd6
Binary files /dev/null and b/Resources/Audio/Items/Taperecorder/taperecorder_play.ogg differ
diff --git a/Resources/Audio/Items/Taperecorder/taperecorder_rewind.ogg b/Resources/Audio/Items/Taperecorder/taperecorder_rewind.ogg
new file mode 100644
index 00000000000..786fc7f4c35
Binary files /dev/null and b/Resources/Audio/Items/Taperecorder/taperecorder_rewind.ogg differ
diff --git a/Resources/Audio/Items/Taperecorder/taperecorder_stop.ogg b/Resources/Audio/Items/Taperecorder/taperecorder_stop.ogg
new file mode 100644
index 00000000000..8adfb0b6f6d
Binary files /dev/null and b/Resources/Audio/Items/Taperecorder/taperecorder_stop.ogg differ
diff --git a/Resources/Locale/en-US/taperecorder/taperecorder.ftl b/Resources/Locale/en-US/taperecorder/taperecorder.ftl
new file mode 100644
index 00000000000..705a3e4527d
--- /dev/null
+++ b/Resources/Locale/en-US/taperecorder/taperecorder.ftl
@@ -0,0 +1,26 @@
+cassette-repair-start = You start winding the tape back into {THE($item)}.
+cassette-repair-finish = You manage to wind the tape back into {THE($item)}.
+tape-cassette-position = The cassette is about [color=green]{$position}%[/color] the way through.
+tape-cassette-damaged = The cassette is unspooled, use a pen or screwdriver to repair it.
+tape-recorder-playing = The tape recorder is in [color=green]playback[/color] mode.
+tape-recorder-stopped = The tape recorder is stopped.
+tape-recorder-empty = The tape recorder is empty.
+tape-recorder-recording = The tape recorder is in [color=red]recording[/color] mode.
+tape-recorder-rewinding = The tape recorder is in [color=yellow]rewinding[/color] mode.
+tape-recorder-locked = Cant eject while the tape recorder is running.
+tape-recorder-voice-unknown = Unknown
+tape-recorder-voice-unintelligible = Unintelligible
+tape-recorder-message-corruption = #
+tape-recorder-menu-title = Tape Recorder
+tape-recorder-menu-controls-label = Controls:
+tape-recorder-menu-stopped-button = Pause
+tape-recorder-menu-recording-button = Record
+tape-recorder-menu-playing-button = Playback
+tape-recorder-menu-rewinding-button = Rewind
+tape-recorder-menu-print-button = Print record transcript
+tape-recorder-menu-cassette-label = Cassette tape: {$cassetteName}
+tape-recorder-menu-no-cassette-label = Cassette tape is not inserted
+tape-recorder-menu-cassette-unnamed = Unnamed
+tape-recorder-print-start-text = [bold]Start of recorded transcript[/bold]
+tape-recorder-print-message-text = [bold][{$time}] {$source}: [/bold] {$message}
+tape-recorder-print-end-text = [bold]End of recorded transcript[/bold]
\ No newline at end of file
diff --git a/Resources/Locale/ru-RU/ss14-ru/prototypes/catalog/fills/boxes/security.ftl b/Resources/Locale/ru-RU/ss14-ru/prototypes/catalog/fills/boxes/security.ftl
index 18108cd83a1..7e41c6630f1 100644
--- a/Resources/Locale/ru-RU/ss14-ru/prototypes/catalog/fills/boxes/security.ftl
+++ b/Resources/Locale/ru-RU/ss14-ru/prototypes/catalog/fills/boxes/security.ftl
@@ -8,3 +8,5 @@ ent-BoxZiptie = коробка стяжек
.desc = Полная коробка кабельных стяжек.
ent-BoxForensicPad = коробка криминалистических пластинок
.desc = Коробка криминалистических пластинок.
+ent-BoxTapeRecorder = коробка с диктофоном
+ .desc = Коробка с разноцветными кассетами и одним диктофоном.
\ No newline at end of file
diff --git a/Resources/Locale/ru-RU/ss14-ru/prototypes/entities/objects/devices/tape_recorder.ftl b/Resources/Locale/ru-RU/ss14-ru/prototypes/entities/objects/devices/tape_recorder.ftl
new file mode 100644
index 00000000000..9cda0069659
--- /dev/null
+++ b/Resources/Locale/ru-RU/ss14-ru/prototypes/entities/objects/devices/tape_recorder.ftl
@@ -0,0 +1,4 @@
+ent-TapeRecorder = диктофон
+ .desc = Всё, что будет записано, будет использовано против вас в суде.
+ent-CassetteTape = кассетная лента
+ .desc = Магнитная лента, которая может вместить до двух минут диалогов с каждой стороны.
\ No newline at end of file
diff --git a/Resources/Locale/ru-RU/ss14-ru/prototypes/entities/objects/misc/paper.ftl b/Resources/Locale/ru-RU/ss14-ru/prototypes/entities/objects/misc/paper.ftl
index 0c47231fe9f..c0a01bdf307 100644
--- a/Resources/Locale/ru-RU/ss14-ru/prototypes/entities/objects/misc/paper.ftl
+++ b/Resources/Locale/ru-RU/ss14-ru/prototypes/entities/objects/misc/paper.ftl
@@ -63,3 +63,4 @@ ent-AllTraitorCodesPaper = реестр кодовых слов Синдикат
.suffix = Адмемы
ent-Envelope = конверт
.desc = Небольшой конверт для защиты от посторонних глаз ваших конфиденциальных документов.
+ent-TapeRecorderTranscript = расшифровка записи
diff --git a/Resources/Locale/ru-RU/taperecorder/taperecorder.ftl b/Resources/Locale/ru-RU/taperecorder/taperecorder.ftl
new file mode 100644
index 00000000000..354cc73d92a
--- /dev/null
+++ b/Resources/Locale/ru-RU/taperecorder/taperecorder.ftl
@@ -0,0 +1,26 @@
+cassette-repair-start = Вы начинаете втягивать ленту обратно в {THE($item)}.
+cassette-repair-finish = Вам удалось намотать ленту обратно в {THE($item)}.
+tape-cassette-position = Кассета проиграна примерно на [color=green]{$position}%[/color].
+tape-cassette-damaged = Кассета размотана. Используйте ручку или отвертку, чтобы намотать ленту обратно.
+tape-recorder-playing = Диктофон находится в режиме [color=green]воспроизведения[/color].
+tape-recorder-stopped = Диктофон остановлен.
+tape-recorder-empty = Диктофон пуст.
+tape-recorder-recording = Диктофон находится в режиме [color=red]записи[/color].
+tape-recorder-rewinding = Диктофон находится в режиме [color=yellow]перемотки[/color].
+tape-recorder-locked = Нельзя извлечь кассету, пока диктофон работает.
+tape-recorder-voice-unknown = Неизвестно
+tape-recorder-voice-unintelligible = Неразборчиво
+tape-recorder-message-corruption = #
+tape-recorder-menu-title = Диктофон
+tape-recorder-menu-controls-label = Управление:
+tape-recorder-menu-stopped-button = Пауза
+tape-recorder-menu-recording-button = Запись
+tape-recorder-menu-playing-button = Воспроизвести
+tape-recorder-menu-rewinding-button = Перемотать
+tape-recorder-menu-print-button = Распечатать расшифровку
+tape-recorder-menu-cassette-label = Кассетная лента: {$cassetteName}
+tape-recorder-menu-no-cassette-label = Кассета не вставлена
+tape-recorder-menu-cassette-unnamed = Название не установлено
+tape-recorder-print-start-text = [bold]Начало записи расшифровки[/bold]
+tape-recorder-print-message-text = [bold][{$time}] {$source}: [/bold] {$message}
+tape-recorder-print-end-text = [bold]Конец записи расшифровки[/bold]
\ No newline at end of file
diff --git a/Resources/Prototypes/Catalog/Fills/Boxes/security.yml b/Resources/Prototypes/Catalog/Fills/Boxes/security.yml
index c43118eff6f..667876affae 100644
--- a/Resources/Prototypes/Catalog/Fills/Boxes/security.yml
+++ b/Resources/Prototypes/Catalog/Fills/Boxes/security.yml
@@ -81,3 +81,26 @@
layers:
- state: box_security
- state: forensic
+
+- type: entity
+ name: tape recorder box
+ parent: BoxCardboard
+ id: BoxTapeRecorder
+ description: A box with colorful cassette tapes and a tape recorder.
+ components:
+ - type: StorageFill
+ contents:
+ - id: CassetteTape
+ amount: 1
+ - id: CassetteTape
+ amount: 1
+ - id: CassetteTape
+ amount: 1
+ - id: CassetteTape
+ amount: 1
+ - id: TapeRecorder
+ amount: 1
+ - type: Sprite
+ layers:
+ - state: box_security
+ - state: recorder
diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml
index 61b0c737108..e7293bd36f5 100644
--- a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml
+++ b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml
@@ -157,6 +157,7 @@
- id: HoloprojectorSecurity
- id: BoxEvidenceMarkers
- id: HandLabeler
+ - id: BoxTapeRecorder
- type: entity
id: ClosetBombFilled
diff --git a/Resources/Prototypes/Entities/Objects/Devices/tape_recorder.yml b/Resources/Prototypes/Entities/Objects/Devices/tape_recorder.yml
new file mode 100644
index 00000000000..52a7c47d881
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Devices/tape_recorder.yml
@@ -0,0 +1,88 @@
+- type: entity
+ parent: BaseItem
+ id: TapeRecorder
+ name: tape recorder
+ description: Anything said into this device can and will be used against you in a court of space law.
+ components:
+ - type: Sprite
+ sprite: Objects/Devices/tape_recorder.rsi
+ layers:
+ - state: taperecorder_empty
+ - state: taperecorder_idle
+ map: ["tape"]
+ visible: false
+ - type: Item
+ size: Small
+ - type: TapeRecorder
+ - type: ActiveListener
+ range: 4
+ - type: UseDelay
+ delay: 1
+ - type: Speech
+ - type: ItemSlots
+ slots:
+ cassette_tape:
+ priority: 4
+ whitelist:
+ components:
+ - FitsInTapeRecorder
+ - type: ContainerContainer
+ containers:
+ cassette_tape: !type:ContainerSlot
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.TapeRecorderVisuals.Mode:
+ tape:
+ Stopped: { state: "taperecorder_idle" }
+ Playing: { state: "taperecorder_playing" }
+ Recording: { state: "taperecorder_recording" }
+ Rewinding: { state: "taperecorder_rewinding" }
+ enum.TapeRecorderVisuals.TapeInserted:
+ tape:
+ True: { visible: true }
+ False: { visible: false }
+ - type: ActivatableUI
+ key: enum.TapeRecorderUIKey.Key
+ inHandsOnly: true
+ requireActiveHand: false
+ - type: UserInterface
+ interfaces:
+ enum.TapeRecorderUIKey.Key:
+ type: TapeRecorderBoundUserInterface
+
+- type: entity
+ parent: BaseItem
+ id: CassetteTape
+ name: cassette tape
+ description: A magnetic tape that can hold up to two minutes of content on either side.
+ components:
+ - type: Sprite
+ sprite: Objects/Devices/cassette_tapes.rsi
+ layers:
+ - state: tape_greyscale
+ map: [ "enum.DamageStateVisualLayers.Base" ]
+ - state: tape_ribbonoverlay
+ map: [ "enum.ToggleVisuals.Layer" ]
+ visible: false
+ - type: Item
+ size: Tiny
+ - type: Damageable
+ - type: TapeCassette
+ maxCapacity: 180
+ repairWhitelist:
+ tags:
+ - Screwdriver
+ - Write
+ - type: FitsInTapeRecorder
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.ToggleVisuals.Toggled:
+ enum.ToggleVisuals.Layer:
+ True: { visible: true }
+ False: { visible: false }
+ - type: RandomSprite
+ available:
+ - enum.DamageStateVisualLayers.Base:
+ tape_greyscale: Rainbow
diff --git a/Resources/Prototypes/Entities/Objects/Misc/paper.yml b/Resources/Prototypes/Entities/Objects/Misc/paper.yml
index 8f994fdea3b..dad550153e5 100644
--- a/Resources/Prototypes/Entities/Objects/Misc/paper.yml
+++ b/Resources/Prototypes/Entities/Objects/Misc/paper.yml
@@ -706,3 +706,9 @@
- !type:EmptyAllContainersBehaviour
- !type:DoActsBehavior
acts: [ "Destruction" ]
+
+- type: entity
+ parent: Paper
+ id: TapeRecorderTranscript
+ name: record transcript
+ # TODO: could have a unique sprite in the future
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index 68656f3c3aa..55d881d1a95 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -201,6 +201,8 @@
- WetFloorSign
- ClothingHeadHatCone
- FreezerElectronics
+ - CassetteTape
+ - TapeRecorder
- PlantAnalyzer # Corvax-Next-PlantAnalyzer
- type: EmagLatheRecipes
emagStaticRecipes:
diff --git a/Resources/Prototypes/Recipes/Lathes/misc.yml b/Resources/Prototypes/Recipes/Lathes/misc.yml
index 7dc77997839..eb27525c376 100644
--- a/Resources/Prototypes/Recipes/Lathes/misc.yml
+++ b/Resources/Prototypes/Recipes/Lathes/misc.yml
@@ -237,3 +237,21 @@
completetime: 3
materials:
Plastic: 200
+
+- type: latheRecipe
+ id: CassetteTape
+ result: CassetteTape
+ category: Tools
+ completetime: 2
+ materials:
+ Steel: 50
+ Plastic: 150
+
+- type: latheRecipe
+ id: TapeRecorder
+ result: TapeRecorder
+ category: Tools
+ completetime: 3
+ materials:
+ Steel: 250
+ Plastic: 250
\ No newline at end of file
diff --git a/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml b/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml
index d0a35990d70..eefd3d75272 100644
--- a/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml
+++ b/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml
@@ -16,6 +16,8 @@
shoes: ClothingShoesColorWhite
id: ReporterPDA
ears: ClothingHeadsetService
- #storage:
- #back:
- #- Stuff
+ storage:
+ back:
+ - TapeRecorder
+ - CassetteTape
+ - CassetteTape
diff --git a/Resources/Textures/Objects/Devices/cassette_tapes.rsi/meta.json b/Resources/Textures/Objects/Devices/cassette_tapes.rsi/meta.json
new file mode 100644
index 00000000000..5e081519e4c
--- /dev/null
+++ b/Resources/Textures/Objects/Devices/cassette_tapes.rsi/meta.json
@@ -0,0 +1,17 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/92dc954ab5317b370e98dd070ad60ba8c3e8a6e9",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "tape_greyscale"
+ },
+ {
+ "name": "tape_ribbonoverlay"
+ }
+ ]
+}
diff --git a/Resources/Textures/Objects/Devices/cassette_tapes.rsi/tape_greyscale.png b/Resources/Textures/Objects/Devices/cassette_tapes.rsi/tape_greyscale.png
new file mode 100644
index 00000000000..9c0c99e09a2
Binary files /dev/null and b/Resources/Textures/Objects/Devices/cassette_tapes.rsi/tape_greyscale.png differ
diff --git a/Resources/Textures/Objects/Devices/cassette_tapes.rsi/tape_ribbonoverlay.png b/Resources/Textures/Objects/Devices/cassette_tapes.rsi/tape_ribbonoverlay.png
new file mode 100644
index 00000000000..f0426c4178c
Binary files /dev/null and b/Resources/Textures/Objects/Devices/cassette_tapes.rsi/tape_ribbonoverlay.png differ
diff --git a/Resources/Textures/Objects/Devices/tape_recorder.rsi/inhand-left.png b/Resources/Textures/Objects/Devices/tape_recorder.rsi/inhand-left.png
new file mode 100644
index 00000000000..c0a8da3279d
Binary files /dev/null and b/Resources/Textures/Objects/Devices/tape_recorder.rsi/inhand-left.png differ
diff --git a/Resources/Textures/Objects/Devices/tape_recorder.rsi/inhand-right.png b/Resources/Textures/Objects/Devices/tape_recorder.rsi/inhand-right.png
new file mode 100644
index 00000000000..fe93fe91185
Binary files /dev/null and b/Resources/Textures/Objects/Devices/tape_recorder.rsi/inhand-right.png differ
diff --git a/Resources/Textures/Objects/Devices/tape_recorder.rsi/meta.json b/Resources/Textures/Objects/Devices/tape_recorder.rsi/meta.json
new file mode 100644
index 00000000000..46b8a14a38e
--- /dev/null
+++ b/Resources/Textures/Objects/Devices/tape_recorder.rsi/meta.json
@@ -0,0 +1,58 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/commit/92dc954ab5317b370e98dd070ad60ba8c3e8a6e9",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "taperecorder_idle"
+ },
+ {
+ "name": "inhand-right",
+ "directions": 4
+ },
+ {
+ "name": "inhand-left",
+ "directions": 4
+ },
+ {
+ "name": "taperecorder_recording",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "taperecorder_playing",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "taperecorder_rewinding",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "taperecorder_empty"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_empty.png b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_empty.png
new file mode 100644
index 00000000000..5e8e0ab3e06
Binary files /dev/null and b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_empty.png differ
diff --git a/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_idle.png b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_idle.png
new file mode 100644
index 00000000000..d4955333695
Binary files /dev/null and b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_idle.png differ
diff --git a/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_playing.png b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_playing.png
new file mode 100644
index 00000000000..57d9ebf4270
Binary files /dev/null and b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_playing.png differ
diff --git a/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_recording.png b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_recording.png
new file mode 100644
index 00000000000..e5fda908c8c
Binary files /dev/null and b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_recording.png differ
diff --git a/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_rewinding.png b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_rewinding.png
new file mode 100644
index 00000000000..3e82112584a
Binary files /dev/null and b/Resources/Textures/Objects/Devices/tape_recorder.rsi/taperecorder_rewinding.png differ
diff --git a/Resources/Textures/Objects/Storage/boxes.rsi/meta.json b/Resources/Textures/Objects/Storage/boxes.rsi/meta.json
index 4301e7afc26..3b4fc1deafa 100644
--- a/Resources/Textures/Objects/Storage/boxes.rsi/meta.json
+++ b/Resources/Textures/Objects/Storage/boxes.rsi/meta.json
@@ -224,6 +224,9 @@
{
"name": "vials"
},
+ {
+ "name": "recorder"
+ },
{
"name": "envelope"
},
diff --git a/Resources/Textures/Objects/Storage/boxes.rsi/recorder.png b/Resources/Textures/Objects/Storage/boxes.rsi/recorder.png
new file mode 100644
index 00000000000..e91a2d02ff0
Binary files /dev/null and b/Resources/Textures/Objects/Storage/boxes.rsi/recorder.png differ