diff --git a/Content.Client/_CD/TapeRecorder/TapeRecorderSystem.cs b/Content.Client/_CD/TapeRecorder/TapeRecorderSystem.cs
new file mode 100644
index 00000000000..3d65cf1032e
--- /dev/null
+++ b/Content.Client/_CD/TapeRecorder/TapeRecorderSystem.cs
@@ -0,0 +1,24 @@
+using Content.Shared._CD.TapeRecorder;
+
+namespace Content.Client._CD.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/_CD/TapeRecorder/Ui/TapeRecorderBoundUserInterface.cs b/Content.Client/_CD/TapeRecorder/Ui/TapeRecorderBoundUserInterface.cs
new file mode 100644
index 00000000000..53ba129114d
--- /dev/null
+++ b/Content.Client/_CD/TapeRecorder/Ui/TapeRecorderBoundUserInterface.cs
@@ -0,0 +1,65 @@
+using Content.Shared._CD.TapeRecorder.Components;
+using Content.Shared._CD.TapeRecorder.Events;
+using JetBrains.Annotations;
+using Robust.Shared.Timing;
+
+namespace Content.Client._CD.TapeRecorder.Ui;
+
+[UsedImplicitly]
+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?.Orphan();
+ }
+}
diff --git a/Content.Client/_CD/TapeRecorder/Ui/TapeRecorderWindow.xaml b/Content.Client/_CD/TapeRecorder/Ui/TapeRecorderWindow.xaml
new file mode 100644
index 00000000000..f807234c1d6
--- /dev/null
+++ b/Content.Client/_CD/TapeRecorder/Ui/TapeRecorderWindow.xaml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_CD/TapeRecorder/Ui/TapeRecorderWindow.xaml.cs b/Content.Client/_CD/TapeRecorder/Ui/TapeRecorderWindow.xaml.cs
new file mode 100644
index 00000000000..d69b3bc4c22
--- /dev/null
+++ b/Content.Client/_CD/TapeRecorder/Ui/TapeRecorderWindow.xaml.cs
@@ -0,0 +1,131 @@
+using Content.Client.UserInterface.Controls;
+using Content.Shared._CD.TapeRecorder.Components;
+using Content.Shared._CD.TapeRecorder.Events;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+
+namespace Content.Client._CD.TapeRecorder.Ui;
+
+[GenerateTypedNameReferences]
+public sealed partial class TapeRecorderWindow : FancyWindow
+{
+ private readonly IEntityManager _entMan;
+
+ private readonly EntityUid _owner;
+ private bool _hasCassette;
+ private TapeRecorderMode _mode = TapeRecorderMode.Stopped;
+
+ private RadioOptions _options;
+ 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;
+ }
+
+ 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
+ _hasCassette = state.HasCassette;
+
+ _updating = true;
+
+ CassetteLabel.Text = _hasCassette
+ ? 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.HasCassette)
+ SetSlider(state.MaxTime, state.CurrentTime);
+
+ _updating = false;
+ }
+
+ private void SetEnabled(TapeRecorderMode mode, bool condition)
+ {
+ _options.SetItemDisabled((int) mode, !(_hasCassette && 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/_CD/TapeRecorder/TapeRecorderSystem.cs b/Content.Server/_CD/TapeRecorder/TapeRecorderSystem.cs
new file mode 100644
index 00000000000..616de8369ef
--- /dev/null
+++ b/Content.Server/_CD/TapeRecorder/TapeRecorderSystem.cs
@@ -0,0 +1,130 @@
+using System.Text;
+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._CD.TapeRecorder;
+using Content.Shared._CD.TapeRecorder.Components;
+using Content.Shared._CD.TapeRecorder.Events;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server._CD.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/_CD/TapeRecorder/Components/ActiveTapeRecorderComponent.cs b/Content.Shared/_CD/TapeRecorder/Components/ActiveTapeRecorderComponent.cs
new file mode 100644
index 00000000000..db089a8ae9f
--- /dev/null
+++ b/Content.Shared/_CD/TapeRecorder/Components/ActiveTapeRecorderComponent.cs
@@ -0,0 +1,9 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._CD.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/_CD/TapeRecorder/Components/FitsInTapeRecorderComponent.cs b/Content.Shared/_CD/TapeRecorder/Components/FitsInTapeRecorderComponent.cs
new file mode 100644
index 00000000000..9e68588f00b
--- /dev/null
+++ b/Content.Shared/_CD/TapeRecorder/Components/FitsInTapeRecorderComponent.cs
@@ -0,0 +1,9 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._CD.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/_CD/TapeRecorder/Components/TapeCassetteComponent.cs b/Content.Shared/_CD/TapeRecorder/Components/TapeCassetteComponent.cs
new file mode 100644
index 00000000000..1367189c4d1
--- /dev/null
+++ b/Content.Shared/_CD/TapeRecorder/Components/TapeCassetteComponent.cs
@@ -0,0 +1,66 @@
+using Content.Shared.Whitelist;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._CD.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;
+
+ ///
+ /// 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();
+}
diff --git a/Content.Shared/_CD/TapeRecorder/Components/TapeRecorderComponent.cs b/Content.Shared/_CD/TapeRecorder/Components/TapeRecorderComponent.cs
new file mode 100644
index 00000000000..07efae3f788
--- /dev/null
+++ b/Content.Shared/_CD/TapeRecorder/Components/TapeRecorderComponent.cs
@@ -0,0 +1,123 @@
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared._CD.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/_CD/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/_CD/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/_CD/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
+}
diff --git a/Content.Shared/_CD/TapeRecorder/Events/TapeRecorderEvents.cs b/Content.Shared/_CD/TapeRecorder/Events/TapeRecorderEvents.cs
new file mode 100644
index 00000000000..3b245ca653e
--- /dev/null
+++ b/Content.Shared/_CD/TapeRecorder/Events/TapeRecorderEvents.cs
@@ -0,0 +1,33 @@
+using Content.Shared.DoAfter;
+using Content.Shared._CD.TapeRecorder.Components;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._CD.TapeRecorder.Events;
+
+[Serializable, NetSerializable]
+public sealed partial class TapeCassetteRepairDoAfterEvent : SimpleDoAfterEvent;
+
+[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(
+ bool hasCassette,
+ float currentTime,
+ float maxTime,
+ string cassetteName,
+ TimeSpan printCooldown): BoundUserInterfaceState
+{
+ // TODO: check the itemslot on client instead of putting easy cassette stuff in the state
+ public bool HasCassette = hasCassette;
+ public float CurrentTime = currentTime;
+ public float MaxTime = maxTime;
+ public string CassetteName = cassetteName;
+ public TimeSpan PrintCooldown = printCooldown;
+}
diff --git a/Content.Shared/_CD/TapeRecorder/SharedTapeRecorderSystem.cs b/Content.Shared/_CD/TapeRecorder/SharedTapeRecorderSystem.cs
new file mode 100644
index 00000000000..ee346aed370
--- /dev/null
+++ b/Content.Shared/_CD/TapeRecorder/SharedTapeRecorderSystem.cs
@@ -0,0 +1,418 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using Content.Shared.Containers.ItemSlots;
+using Content.Shared.Damage;
+using Content.Shared.DoAfter;
+using Content.Shared.Examine;
+using Content.Shared.Interaction;
+using Content.Shared.Labels.Components;
+using Content.Shared._CD.TapeRecorder.Components;
+using Content.Shared._CD.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;
+
+namespace Content.Shared._CD.TapeRecorder;
+
+public abstract class SharedTapeRecorderSystem : EntitySystem
+{
+ [Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly ItemSlotsSystem _slots = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+ [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+ [Dependency] private readonly SharedUserInterfaceSystem _ui = default!;
+ [Dependency] protected readonly IGameTiming Timing = default!;
+ [Dependency] protected readonly SharedAudioSystem Audio = default!;
+
+ private 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
+ ///
+ private 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
+ ///
+ private 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
+ ///
+ private 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);
+ }
+
+ private 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);
+ }
+
+ private 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
+ ///
+ private void OnCassetteRemoveAttempt(Entity ent, ref ItemSlotEjectAttemptEvent args)
+ {
+ if (!HasComp(ent))
+ return;
+
+ args.Cancelled = true;
+ }
+
+ private void OnCassetteRemoved(Entity ent, ref EntRemovedFromContainerMessage args)
+ {
+ SetMode(ent, TapeRecorderMode.Stopped);
+ UpdateAppearance(ent);
+ UpdateUI(ent);
+ }
+
+ private void OnCassetteInserted(Entity ent, ref EntInsertedIntoContainerMessage args)
+ {
+ UpdateAppearance(ent);
+ UpdateUI(ent);
+ }
+
+ ///
+ /// Update the appearance of the tape recorder.
+ ///
+ /// The tape recorder to update
+ private 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
+ ///
+ private 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, 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,
+ currentTime,
+ maxTime,
+ cassetteName,
+ cooldown);
+
+ _ui.SetUiState(uid, TapeRecorderUIKey.Key, state);
+ }
+}
diff --git a/Content.Shared/_CD/TapeRecorder/TapeCassetteRecordedMessage.cs b/Content.Shared/_CD/TapeRecorder/TapeCassetteRecordedMessage.cs
new file mode 100644
index 00000000000..81973254f5b
--- /dev/null
+++ b/Content.Shared/_CD/TapeRecorder/TapeCassetteRecordedMessage.cs
@@ -0,0 +1,51 @@
+using Content.Shared.Speech;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared._CD.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;
+
+ ///
+ /// 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/_CD/Items/Taperecorder/attributions.yml b/Resources/Audio/_CD/Items/Taperecorder/attributions.yml
new file mode 100644
index 00000000000..48e0b3e032f
--- /dev/null
+++ b/Resources/Audio/_CD/Items/Taperecorder/attributions.yml
@@ -0,0 +1,14 @@
+- 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/"
diff --git a/Resources/Audio/_CD/Items/Taperecorder/taperecorder_play.ogg b/Resources/Audio/_CD/Items/Taperecorder/taperecorder_play.ogg
new file mode 100644
index 00000000000..1bf4d7a3bd6
Binary files /dev/null and b/Resources/Audio/_CD/Items/Taperecorder/taperecorder_play.ogg differ
diff --git a/Resources/Audio/_CD/Items/Taperecorder/taperecorder_rewind.ogg b/Resources/Audio/_CD/Items/Taperecorder/taperecorder_rewind.ogg
new file mode 100644
index 00000000000..786fc7f4c35
Binary files /dev/null and b/Resources/Audio/_CD/Items/Taperecorder/taperecorder_rewind.ogg differ
diff --git a/Resources/Audio/_CD/Items/Taperecorder/taperecorder_stop.ogg b/Resources/Audio/_CD/Items/Taperecorder/taperecorder_stop.ogg
new file mode 100644
index 00000000000..8adfb0b6f6d
Binary files /dev/null and b/Resources/Audio/_CD/Items/Taperecorder/taperecorder_stop.ogg differ
diff --git a/Resources/Locale/en-US/_CD/taperecorder/taperecorder.ftl b/Resources/Locale/en-US/_CD/taperecorder/taperecorder.ftl
new file mode 100644
index 00000000000..24315ed3703
--- /dev/null
+++ b/Resources/Locale/en-US/_CD/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 68b52e68db7..9f757cf2cd4 100644
--- a/Resources/Prototypes/Catalog/Fills/Lockers/security.yml
+++ b/Resources/Prototypes/Catalog/Fills/Lockers/security.yml
@@ -146,6 +146,7 @@
- id: HandLabeler
#CD Addions
- id: BoxFolderDetectiveClipboard
+ - id: BoxTapeRecorder
- id: BookSOPSecurity
prob: 0.2
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index c02c39f68ea..015043f367f 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -202,6 +202,8 @@
- WetFloorSign
- ClothingHeadHatCone
- FreezerElectronics
+ - CassetteTape #CD Change
+ - TapeRecorder #CD Change
- type: EmagLatheRecipes
emagStaticRecipes:
- BoxLethalshot
diff --git a/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml b/Resources/Prototypes/Roles/Jobs/Wildcards/reporter.yml
index d0a35990d70..9176ac65daa 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 # CD Change
+ - CassetteTape # CD Change
+ - CassetteTape # CD Change
diff --git a/Resources/Prototypes/_CD/Catalog/Fills/Boxes/security.yml b/Resources/Prototypes/_CD/Catalog/Fills/Boxes/security.yml
new file mode 100644
index 00000000000..5f48ca6ecb5
--- /dev/null
+++ b/Resources/Prototypes/_CD/Catalog/Fills/Boxes/security.yml
@@ -0,0 +1,21 @@
+- 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
+ sprite: _CD/Objects/Storage/boxes.rsi
+ state: recorder
\ No newline at end of file
diff --git a/Resources/Prototypes/_CD/Entities/Objects/Devices/tape_recorder.yml b/Resources/Prototypes/_CD/Entities/Objects/Devices/tape_recorder.yml
new file mode 100644
index 00000000000..5b33f245f19
--- /dev/null
+++ b/Resources/Prototypes/_CD/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: _CD/Objects/Devices/tape_recorder.rsi
+ layers:
+ - state: taperecorder_empty
+ - state: taperecorder_idle
+ map: ["tape"]
+ visible: false
+ - type: Item
+ size: 10
+ - 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: _CD/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: 5
+ - 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
\ No newline at end of file
diff --git a/Resources/Prototypes/_CD/Entities/Objects/Misc/paper.yml b/Resources/Prototypes/_CD/Entities/Objects/Misc/paper.yml
index b84bb717d38..958f3b08117 100644
--- a/Resources/Prototypes/_CD/Entities/Objects/Misc/paper.yml
+++ b/Resources/Prototypes/_CD/Entities/Objects/Misc/paper.yml
@@ -31,3 +31,9 @@
damage:
types:
Blunt: 8 # It's bigger, and heavy, it does an extra 2 damage vs the regular clipboard.
+
+# Casset Transcript
+- type: entity
+ parent: Paper
+ id: TapeRecorderTranscript
+ name: record transcript
\ No newline at end of file
diff --git a/Resources/Prototypes/_CD/Recipes/Lathes/misc.yml b/Resources/Prototypes/_CD/Recipes/Lathes/misc.yml
new file mode 100644
index 00000000000..95c25820ffa
--- /dev/null
+++ b/Resources/Prototypes/_CD/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
\ No newline at end of file
diff --git a/Resources/Prototypes/_CD/Roles/Jobs/Civilian/private_investigator.yml b/Resources/Prototypes/_CD/Roles/Jobs/Civilian/private_investigator.yml
index d66c1dcc918..ba7cd9f60fe 100644
--- a/Resources/Prototypes/_CD/Roles/Jobs/Civilian/private_investigator.yml
+++ b/Resources/Prototypes/_CD/Roles/Jobs/Civilian/private_investigator.yml
@@ -22,3 +22,4 @@
- ForensicPad
- ForensicPad
- LogProbeCartridge
+ - BoxTapeRecorder
diff --git a/Resources/Textures/_CD/Objects/Devices/cassette_tapes.rsi/meta.json b/Resources/Textures/_CD/Objects/Devices/cassette_tapes.rsi/meta.json
new file mode 100644
index 00000000000..a02c0eafe47
--- /dev/null
+++ b/Resources/Textures/_CD/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"
+ }
+ ]
+ }
\ No newline at end of file
diff --git a/Resources/Textures/_CD/Objects/Devices/cassette_tapes.rsi/tape_greyscale.png b/Resources/Textures/_CD/Objects/Devices/cassette_tapes.rsi/tape_greyscale.png
new file mode 100644
index 00000000000..9c0c99e09a2
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Devices/cassette_tapes.rsi/tape_greyscale.png differ
diff --git a/Resources/Textures/_CD/Objects/Devices/cassette_tapes.rsi/tape_ribbonoverlay.png b/Resources/Textures/_CD/Objects/Devices/cassette_tapes.rsi/tape_ribbonoverlay.png
new file mode 100644
index 00000000000..f0426c4178c
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Devices/cassette_tapes.rsi/tape_ribbonoverlay.png differ
diff --git a/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/inhand-left.png b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/inhand-left.png
new file mode 100644
index 00000000000..c0a8da3279d
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/inhand-left.png differ
diff --git a/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/inhand-right.png b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/inhand-right.png
new file mode 100644
index 00000000000..fe93fe91185
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/inhand-right.png differ
diff --git a/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/meta.json b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/meta.json
new file mode 100644
index 00000000000..c7b22b9c45c
--- /dev/null
+++ b/Resources/Textures/_CD/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"
+ }
+ ]
+}
diff --git a/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_empty.png b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_empty.png
new file mode 100644
index 00000000000..5e8e0ab3e06
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_empty.png differ
diff --git a/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_idle.png b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_idle.png
new file mode 100644
index 00000000000..d4955333695
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_idle.png differ
diff --git a/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_playing.png b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_playing.png
new file mode 100644
index 00000000000..57d9ebf4270
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_playing.png differ
diff --git a/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_recording.png b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_recording.png
new file mode 100644
index 00000000000..e5fda908c8c
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_recording.png differ
diff --git a/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_rewinding.png b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_rewinding.png
new file mode 100644
index 00000000000..3e82112584a
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Devices/tape_recorder.rsi/taperecorder_rewinding.png differ
diff --git a/Resources/Textures/_CD/Objects/Storage/boxes.rsi/meta.json b/Resources/Textures/_CD/Objects/Storage/boxes.rsi/meta.json
index 1547c0818d4..6136d7196aa 100644
--- a/Resources/Textures/_CD/Objects/Storage/boxes.rsi/meta.json
+++ b/Resources/Textures/_CD/Objects/Storage/boxes.rsi/meta.json
@@ -1,7 +1,7 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
- "copyright": "Based off of tgstation at commit https://github.com/tgstation/tgstation/commit/cc65477c04f7403ca8a457bd5bae69a01abadbf0 'pride' made by PursuitInAshes(github).",
+ "copyright": "Based off of tgstation at commit https://github.com/tgstation/tgstation/commit/cc65477c04f7403ca8a457bd5bae69a01abadbf0 'pride' made by PursuitInAshes(github), recorder based off of tgstation at https://github.com/tgstation/tgstation/commit/92dc954ab5317b370e98dd070ad60ba8c3e8a6e9",
"size": {
"x": 32,
"y": 32
@@ -9,6 +9,9 @@
"states": [
{
"name": "pride"
+ },
+ {
+ "name": "recorder"
}
]
}
diff --git a/Resources/Textures/_CD/Objects/Storage/boxes.rsi/recorder.png b/Resources/Textures/_CD/Objects/Storage/boxes.rsi/recorder.png
new file mode 100644
index 00000000000..046f9997d0d
Binary files /dev/null and b/Resources/Textures/_CD/Objects/Storage/boxes.rsi/recorder.png differ