diff --git a/Content.Client/DeltaV/TapeRecorder/TapeRecorderSystem.cs b/Content.Client/DeltaV/TapeRecorder/TapeRecorderSystem.cs
new file mode 100644
index 00000000000..7b8ed6663ca
--- /dev/null
+++ b/Content.Client/DeltaV/TapeRecorder/TapeRecorderSystem.cs
@@ -0,0 +1,24 @@
+using Content.Shared.DeltaV.TapeRecorder.Systems;
+
+namespace Content.Client.DeltaV.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);
+ }
+}
diff --git a/Content.Client/DeltaV/TapeRecorder/UI/TapeRecorderBoundUserInterface.cs b/Content.Client/DeltaV/TapeRecorder/UI/TapeRecorderBoundUserInterface.cs
new file mode 100644
index 00000000000..521fbb96247
--- /dev/null
+++ b/Content.Client/DeltaV/TapeRecorder/UI/TapeRecorderBoundUserInterface.cs
@@ -0,0 +1,49 @@
+using Content.Shared.DeltaV.TapeRecorder;
+using Robust.Client.UserInterface;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+
+namespace Content.Client.DeltaV.TapeRecorder.UI;
+
+public sealed class TapeRecorderBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
+{
+ [ViewVariables]
+ private TapeRecorderWindow? _window;
+
+ [ViewVariables]
+ private TimeSpan _printCooldown;
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow();
+ _window.Owner = Owner;
+ _window.OnModeChanged += mode => SendMessage(new ChangeModeTapeRecorderMessage(mode));
+ _window.OnPrintTranscript += PrintTranscript;
+ }
+
+ 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);
+ }
+}
diff --git a/Content.Client/DeltaV/TapeRecorder/UI/TapeRecorderWindow.xaml b/Content.Client/DeltaV/TapeRecorder/UI/TapeRecorderWindow.xaml
new file mode 100644
index 00000000000..c6dc577d264
--- /dev/null
+++ b/Content.Client/DeltaV/TapeRecorder/UI/TapeRecorderWindow.xaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/DeltaV/TapeRecorder/UI/TapeRecorderWindow.xaml.cs b/Content.Client/DeltaV/TapeRecorder/UI/TapeRecorderWindow.xaml.cs
new file mode 100644
index 00000000000..c0edaecf453
--- /dev/null
+++ b/Content.Client/DeltaV/TapeRecorder/UI/TapeRecorderWindow.xaml.cs
@@ -0,0 +1,129 @@
+using Content.Client.UserInterface.Controls;
+using Content.Shared.DeltaV.TapeRecorder;
+using Content.Shared.DeltaV.TapeRecorder.Components;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+
+namespace Content.Client.DeltaV.TapeRecorder.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class TapeRecorderWindow : FancyWindow
+{
+ [Dependency] private readonly IEntityManager _entMan = default!;
+
+ public 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()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ _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;
+ }
+}
diff --git a/Content.Server/DeltaV/TapeRecorder/TapeRecorderSystem.cs b/Content.Server/DeltaV/TapeRecorder/TapeRecorderSystem.cs
new file mode 100644
index 00000000000..c4e594ab98c
--- /dev/null
+++ b/Content.Server/DeltaV/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.DeltaV.TapeRecorder;
+using Content.Shared.DeltaV.TapeRecorder.Components;
+using Content.Shared.DeltaV.TapeRecorder.Systems;
+using Robust.Server.Audio;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using System.Text;
+
+namespace Content.Server.DeltaV.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/DeltaV/TapeRecorder/Components/ActiveTapeRecorderComponent.cs b/Content.Shared/DeltaV/TapeRecorder/Components/ActiveTapeRecorderComponent.cs
new file mode 100644
index 00000000000..a3562370401
--- /dev/null
+++ b/Content.Shared/DeltaV/TapeRecorder/Components/ActiveTapeRecorderComponent.cs
@@ -0,0 +1,9 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.DeltaV.TapeRecorder.Components;
+
+///
+/// Added to tape records that are updating, winding or rewinding the tape.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class ActiveTapeRecorderComponent : Component;
diff --git a/Content.Shared/DeltaV/TapeRecorder/Components/FitsInTapeRecorderComponent.cs b/Content.Shared/DeltaV/TapeRecorder/Components/FitsInTapeRecorderComponent.cs
new file mode 100644
index 00000000000..7e4c461973f
--- /dev/null
+++ b/Content.Shared/DeltaV/TapeRecorder/Components/FitsInTapeRecorderComponent.cs
@@ -0,0 +1,9 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.DeltaV.TapeRecorder.Components;
+
+///
+/// Removed from the cassette when damaged to prevent it being played until repaired
+///
+[RegisterComponent, NetworkedComponent]
+public sealed partial class FitsInTapeRecorderComponent : Component;
diff --git a/Content.Shared/DeltaV/TapeRecorder/Components/TapeCasetteComponent.cs b/Content.Shared/DeltaV/TapeRecorder/Components/TapeCasetteComponent.cs
new file mode 100644
index 00000000000..a11be3c64a3
--- /dev/null
+++ b/Content.Shared/DeltaV/TapeRecorder/Components/TapeCasetteComponent.cs
@@ -0,0 +1,53 @@
+using Content.Shared.DeltaV.TapeRecorder.Systems;
+using Content.Shared.Whitelist;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.DeltaV.TapeRecorder.Components;
+
+[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;
+
+ ///
+ /// 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();
+}
diff --git a/Content.Shared/DeltaV/TapeRecorder/Components/TapeRecorderComponent.cs b/Content.Shared/DeltaV/TapeRecorder/Components/TapeRecorderComponent.cs
new file mode 100644
index 00000000000..c5600b8bcf6
--- /dev/null
+++ b/Content.Shared/DeltaV/TapeRecorder/Components/TapeRecorderComponent.cs
@@ -0,0 +1,83 @@
+using Content.Shared.DeltaV.TapeRecorder.Systems;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.DeltaV.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/DeltaV/Items/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/DeltaV/Items/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/DeltaV/Items/TapeRecorder/rewind.ogg")
+ {
+ Params = AudioParams.Default.WithVolume(-2f).WithMaxDistance(3f)
+ };
+}
diff --git a/Content.Shared/DeltaV/TapeRecorder/Systems/SharedTapeRecorderSystem.cs b/Content.Shared/DeltaV/TapeRecorder/Systems/SharedTapeRecorderSystem.cs
new file mode 100644
index 00000000000..34ff5c348d7
--- /dev/null
+++ b/Content.Shared/DeltaV/TapeRecorder/Systems/SharedTapeRecorderSystem.cs
@@ -0,0 +1,419 @@
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.Damage;
+using Content.Shared.DeltaV.TapeRecorder.Components;
+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.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.Serialization;
+using Robust.Shared.Timing;
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+
+namespace Content.Shared.DeltaV.TapeRecorder.Systems;
+
+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("tape-cassette-damaged"));
+ return;
+ }
+
+ var positionPercentage = Math.Floor(ent.Comp.CurrentPosition / ent.Comp.MaxCapacity.TotalSeconds * 100);
+ var tapePosMsg = Loc.GetString("tape-cassette-position", ("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("tape-recorder-empty"));
+ return;
+ }
+
+ var state = ent.Comp.Mode.ToString().ToLower();
+ args.PushMarkup(Loc.GetString("tape-recorder-" + state));
+
+ 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-recorder-message-corruption");
+
+ 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-recorder-voice-unintelligible");
+ 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 = "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);
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed partial class TapeCassetteRepairDoAfterEvent : SimpleDoAfterEvent;
diff --git a/Content.Shared/DeltaV/TapeRecorder/TapeCasetteRecordedMessage.cs b/Content.Shared/DeltaV/TapeRecorder/TapeCasetteRecordedMessage.cs
new file mode 100644
index 00000000000..92828b28302
--- /dev/null
+++ b/Content.Shared/DeltaV/TapeRecorder/TapeCasetteRecordedMessage.cs
@@ -0,0 +1,51 @@
+using Content.Shared.Speech;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.DeltaV.TapeRecorder;
+
+///
+/// Every chat event recorded on a tape is saved in this format
+///
+[DataDefinition]
+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/Content.Shared/DeltaV/TapeRecorder/TapeRecorderUI.cs b/Content.Shared/DeltaV/TapeRecorder/TapeRecorderUI.cs
new file mode 100644
index 00000000000..3a616cf8ffc
--- /dev/null
+++ b/Content.Shared/DeltaV/TapeRecorder/TapeRecorderUI.cs
@@ -0,0 +1,62 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.DeltaV.TapeRecorder;
+
+[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
+}
+
+[Serializable, NetSerializable]
+public sealed class ChangeModeTapeRecorderMessage(TapeRecorderMode mode) : BoundUserInterfaceMessage
+{
+ public TapeRecorderMode 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/Resources/Audio/DeltaV/Items/TapeRecorder/attributions.yml b/Resources/Audio/DeltaV/Items/TapeRecorder/attributions.yml
new file mode 100644
index 00000000000..848a7979072
--- /dev/null
+++ b/Resources/Audio/DeltaV/Items/TapeRecorder/attributions.yml
@@ -0,0 +1,14 @@
+- files: [ "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: [ "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: [ "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/"
diff --git a/Resources/Audio/DeltaV/Items/TapeRecorder/play.ogg b/Resources/Audio/DeltaV/Items/TapeRecorder/play.ogg
new file mode 100644
index 00000000000..1bf4d7a3bd6
Binary files /dev/null and b/Resources/Audio/DeltaV/Items/TapeRecorder/play.ogg differ
diff --git a/Resources/Audio/DeltaV/Items/TapeRecorder/rewind.ogg b/Resources/Audio/DeltaV/Items/TapeRecorder/rewind.ogg
new file mode 100644
index 00000000000..786fc7f4c35
Binary files /dev/null and b/Resources/Audio/DeltaV/Items/TapeRecorder/rewind.ogg differ
diff --git a/Resources/Audio/DeltaV/Items/TapeRecorder/stop.ogg b/Resources/Audio/DeltaV/Items/TapeRecorder/stop.ogg
new file mode 100644
index 00000000000..8adfb0b6f6d
Binary files /dev/null and b/Resources/Audio/DeltaV/Items/TapeRecorder/stop.ogg differ
diff --git a/Resources/Locale/en-US/deltav/taperecorder/taperecorder.ftl b/Resources/Locale/en-US/deltav/taperecorder/taperecorder.ftl
new file mode 100644
index 00000000000..24315ed3703
--- /dev/null
+++ b/Resources/Locale/en-US/deltav/taperecorder/taperecorder.ftl
@@ -0,0 +1,27 @@
+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-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]
diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml
index fe3b1ebaba1..43a3c794252 100644
--- a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml
+++ b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml
@@ -203,6 +203,7 @@
- id: HoloprojectorSecurity
- id: BoxEvidenceMarkers
- id: HandLabeler
+ - id: BoxTapeRecorder # DeltaV
- id: LunchboxSecurityFilledRandom # Delta-v Lunchboxes!
prob: 0.3
diff --git a/Resources/Prototypes/DeltaV/Catalog/Fills/Boxes/security.yml b/Resources/Prototypes/DeltaV/Catalog/Fills/Boxes/security.yml
new file mode 100644
index 00000000000..769abfd2a7d
--- /dev/null
+++ b/Resources/Prototypes/DeltaV/Catalog/Fills/Boxes/security.yml
@@ -0,0 +1,16 @@
+- type: entity
+ parent: BoxCardboard
+ id: BoxTapeRecorder
+ name: tape recorder box
+ description: A box with colorful cassette tapes and a tape recorder.
+ components:
+ - type: Sprite
+ layers:
+ - state: box_security
+ - sprite: DeltaV/Objects/Storage/boxes.rsi
+ state: recorder
+ - type: StorageFill
+ contents:
+ - id: CassetteTape
+ amount: 4
+ - id: TapeRecorder
diff --git a/Resources/Prototypes/DeltaV/Entities/Objects/Devices/tape_recorder.yml b/Resources/Prototypes/DeltaV/Entities/Objects/Devices/tape_recorder.yml
new file mode 100644
index 00000000000..dbebdd1b9d0
--- /dev/null
+++ b/Resources/Prototypes/DeltaV/Entities/Objects/Devices/tape_recorder.yml
@@ -0,0 +1,147 @@
+- 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: DeltaV/Objects/Devices/tape_recorder.rsi
+ layers:
+ - state: empty
+ - state: 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: "idle" }
+ Playing: { state: "playing" }
+ Recording: { state: "recording" }
+ Rewinding: { state: "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: TapeRecorder
+ id: TapeRecorderFilled
+ suffix: Filled
+ components:
+ - type: ContainerFill
+ containers:
+ cassette_tape:
+ - CassetteTape
+
+- 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: DeltaV/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
+
+- type: entity
+ suffix: Interview with Garry Smosh
+ parent: CassetteTape
+ id: CassetteTapeInterview
+ components:
+ - type: Label
+ currentLabel: Interview with Garry Smosh
+ - type: TapeCassette
+ recordedData:
+ - timestamp: 2
+ name: Phil Dervin
+ message: "Its 11:43am, present in the room are Phil Dervin, Detective first class, Officer Belview and Grarry Smosh, Suspect of one count of secure tresspass, four counts of assault, two counts of theft and 85 counts of disturbing the peace."
+ - timestamp: 6
+ name: Phil Dervin
+ message: "Mr Smosh, do you understand the charges you have been accused of?"
+ - timestamp: 14
+ name: Grarry Smosh
+ message: "I don't care what you say, i ain't done anything."
+ - timestamp: 18
+ name: Phil Dervin
+ message: "Sir, you were caught redhanded in the Captains bedroom. In the middle of an attempt at stealing his whiskey reserve no less."
+ - timestamp: 23
+ name: Phil Dervin
+ message: "You are lucky he didn't shoot you for that."
+ - timestamp: 28
+ name: Grarry Smosh
+ message: "I didn't see no signs saying i couldn't be there."
+ - timestamp: 34
+ name: Phil Dervin
+ message: "The Captains bedroom? I don't think we need a sign telling people to stay out - it's common sense."
+ - timestamp: 38
+ name: Phil Dervin
+ message: "Anyway that's besides the point, even if it were not off limits there is still the matter of the restricted items we found on your person and the subsequent attempt at evading arrest."
+ - timestamp: 42
+ name: Grarry Smosh
+ message: "I ain't done nothing."
+ - timestamp: 46
+ name: Officer Belview
+ message: "You slipped 3 officers, stole a stun baton and beat Ian with it. The HOP was very upset at that last part."
+ - timestamp: 50
+ name: Grarry Smosh
+ message: "Which one of you gave the HOP a disabler?"
+ - timestamp: 54
+ name: Phil Dervin
+ message: "The Warden did, turned out to be a good idea eh?"
+ - timestamp: 58
+ name: Officer Belview
+ message: "I would say so."
diff --git a/Resources/Prototypes/DeltaV/Entities/Objects/Misc/paper.yml b/Resources/Prototypes/DeltaV/Entities/Objects/Misc/paper.yml
index 2682f83ab88..7f8a5b862b3 100644
--- a/Resources/Prototypes/DeltaV/Entities/Objects/Misc/paper.yml
+++ b/Resources/Prototypes/DeltaV/Entities/Objects/Misc/paper.yml
@@ -91,3 +91,9 @@
before consuming.
Enjoy your drinks!
+
+- type: entity
+ parent: Paper
+ id: TapeRecorderTranscript
+ name: record transcript
+ # TODO: could have a unique sprite in the future
diff --git a/Resources/Prototypes/DeltaV/Loadouts/Miscellaneous/trinkets.yml b/Resources/Prototypes/DeltaV/Loadouts/Miscellaneous/trinkets.yml
index be1f4227763..ae213aaaa58 100644
--- a/Resources/Prototypes/DeltaV/Loadouts/Miscellaneous/trinkets.yml
+++ b/Resources/Prototypes/DeltaV/Loadouts/Miscellaneous/trinkets.yml
@@ -27,3 +27,9 @@
storage:
back:
- WhiteCane
+
+- type: loadout
+ id: TapeRecorder
+ storage:
+ back:
+ - TapeRecorderFilled
diff --git a/Resources/Prototypes/DeltaV/Recipes/Lathes/misc.yml b/Resources/Prototypes/DeltaV/Recipes/Lathes/misc.yml
new file mode 100644
index 00000000000..4c6af83db85
--- /dev/null
+++ b/Resources/Prototypes/DeltaV/Recipes/Lathes/misc.yml
@@ -0,0 +1,17 @@
+- 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
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index 14a6981a49d..eb57f52542e 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -206,6 +206,8 @@
- SilverRing
- GoldRingDiamond
- SilverRingDiamond
+ - CassetteTape
+ - TapeRecorder
# End Delta-V additions
- type: EmagLatheRecipes
emagStaticRecipes:
diff --git a/Resources/Prototypes/Loadouts/loadout_groups.yml b/Resources/Prototypes/Loadouts/loadout_groups.yml
index d801a9fce93..e22e6889946 100644
--- a/Resources/Prototypes/Loadouts/loadout_groups.yml
+++ b/Resources/Prototypes/Loadouts/loadout_groups.yml
@@ -45,12 +45,15 @@
- TowelColorSilver
- TowelColorWhite
- TowelColorYellow
- - AACTablet # DeltaV
- - GoldRing # DeltaV
- - SilverRing # DeltaV
- - Cane # DeltaV
- - WhiteCane #DeltaV
- - CDDogtags # _CD
+ # Begin DeltaV Additions
+ - AACTablet
+ - GoldRing
+ - SilverRing
+ - Cane
+ - WhiteCane
+ - CDDogtags # taken from Cosmatic Drift
+ - TapeRecorder
+ # End DeltaV Additions
- type: loadoutGroup
id: Glasses
diff --git a/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml b/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml
index 4fe57726c24..b3bc1559e78 100644
--- a/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml
+++ b/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml
@@ -21,6 +21,8 @@
shoes: ClothingShoesColorWhite
id: ReporterPDA
ears: ClothingHeadsetService
- #storage:
- #back:
- #- Stuff
+ storage: # DeltaV: Give reporters tape recording equipment
+ back:
+ - TapeRecorder
+ - CassetteTape
+ - CassetteTape
diff --git a/Resources/Textures/DeltaV/Objects/Devices/cassette_tapes.rsi/meta.json b/Resources/Textures/DeltaV/Objects/Devices/cassette_tapes.rsi/meta.json
new file mode 100644
index 00000000000..db55b94ff4a
--- /dev/null
+++ b/Resources/Textures/DeltaV/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/DeltaV/Objects/Devices/cassette_tapes.rsi/tape_greyscale.png b/Resources/Textures/DeltaV/Objects/Devices/cassette_tapes.rsi/tape_greyscale.png
new file mode 100644
index 00000000000..9c0c99e09a2
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/cassette_tapes.rsi/tape_greyscale.png differ
diff --git a/Resources/Textures/DeltaV/Objects/Devices/cassette_tapes.rsi/tape_ribbonoverlay.png b/Resources/Textures/DeltaV/Objects/Devices/cassette_tapes.rsi/tape_ribbonoverlay.png
new file mode 100644
index 00000000000..f0426c4178c
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/cassette_tapes.rsi/tape_ribbonoverlay.png differ
diff --git a/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/empty.png b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/empty.png
new file mode 100644
index 00000000000..5e8e0ab3e06
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/empty.png differ
diff --git a/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/idle.png b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/idle.png
new file mode 100644
index 00000000000..d4955333695
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/idle.png differ
diff --git a/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/inhand-left.png b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/inhand-left.png
new file mode 100644
index 00000000000..c0a8da3279d
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/inhand-left.png differ
diff --git a/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/inhand-right.png b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/inhand-right.png
new file mode 100644
index 00000000000..fe93fe91185
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/inhand-right.png differ
diff --git a/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/meta.json b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/meta.json
new file mode 100644
index 00000000000..1eb82b58874
--- /dev/null
+++ b/Resources/Textures/DeltaV/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": "idle"
+ },
+ {
+ "name": "inhand-right",
+ "directions": 4
+ },
+ {
+ "name": "inhand-left",
+ "directions": 4
+ },
+ {
+ "name": "recording",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "playing",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "rewinding",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "empty"
+ }
+ ]
+}
diff --git a/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/playing.png b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/playing.png
new file mode 100644
index 00000000000..57d9ebf4270
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/playing.png differ
diff --git a/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/recording.png b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/recording.png
new file mode 100644
index 00000000000..e5fda908c8c
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/recording.png differ
diff --git a/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/rewinding.png b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/rewinding.png
new file mode 100644
index 00000000000..3e82112584a
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Devices/tape_recorder.rsi/rewinding.png differ
diff --git a/Resources/Textures/DeltaV/Objects/Storage/boxes.rsi/meta.json b/Resources/Textures/DeltaV/Objects/Storage/boxes.rsi/meta.json
new file mode 100644
index 00000000000..39395031df2
--- /dev/null
+++ b/Resources/Textures/DeltaV/Objects/Storage/boxes.rsi/meta.json
@@ -0,0 +1,14 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Maybe taken from /tg/station. No attribution was present originally, so this is only an assumption.",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "recorder"
+ }
+ ]
+}
diff --git a/Resources/Textures/DeltaV/Objects/Storage/boxes.rsi/recorder.png b/Resources/Textures/DeltaV/Objects/Storage/boxes.rsi/recorder.png
new file mode 100644
index 00000000000..e91a2d02ff0
Binary files /dev/null and b/Resources/Textures/DeltaV/Objects/Storage/boxes.rsi/recorder.png differ