diff --git a/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs b/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs
new file mode 100644
index 0000000000..b4947733f0
--- /dev/null
+++ b/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
+using System.Linq;
+using Content.Shared.Instruments;
+using Robust.Shared.Audio.Midi;
+
+namespace Content.Client.Instruments;
+
+public sealed partial class InstrumentSystem
+{
+ ///
+ /// Tries to parse the input data as a midi and set the channel names respectively.
+ ///
+ ///
+ /// Thank you to http://www.somascape.org/midi/tech/mfile.html for providing an awesome resource for midi files.
+ ///
+ ///
+ /// This method has exception tolerance and does not throw, even if the midi file is invalid.
+ ///
+ private bool TrySetChannels(EntityUid uid, byte[] data)
+ {
+ if (!MidiParser.MidiParser.TryGetMidiTracks(data, out var tracks, out var error))
+ {
+ Log.Error(error);
+ return false;
+ }
+
+ var resolvedTracks = new List();
+ for (var index = 0; index < tracks.Length; index++)
+ {
+ var midiTrack = tracks[index];
+ if (midiTrack is { TrackName: null, ProgramName: null, InstrumentName: null})
+ continue;
+
+ switch (midiTrack)
+ {
+ case { TrackName: not null, ProgramName: not null }:
+ case { TrackName: not null, InstrumentName: not null }:
+ case { TrackName: not null }:
+ case { ProgramName: not null }:
+ resolvedTracks.Add(midiTrack);
+ break;
+ default:
+ resolvedTracks.Add(null); // Used so the channel still displays as MIDI Channel X and doesn't just take the next valid one in the UI
+ break;
+ }
+
+ Log.Debug($"Channel name: {resolvedTracks.Last()}");
+ }
+
+ RaiseNetworkEvent(new InstrumentSetChannelsEvent(GetNetEntity(uid), resolvedTracks.Take(RobustMidiEvent.MaxChannels).ToArray()));
+ Log.Debug($"Resolved {resolvedTracks.Count} channels.");
+
+ return true;
+ }
+}
diff --git a/Content.Client/Instruments/InstrumentSystem.cs b/Content.Client/Instruments/InstrumentSystem.cs
index 5bdaa52359..609aa37788 100644
--- a/Content.Client/Instruments/InstrumentSystem.cs
+++ b/Content.Client/Instruments/InstrumentSystem.cs
@@ -1,3 +1,20 @@
+// SPDX-FileCopyrightText: 2020 Vince
+// SPDX-FileCopyrightText: 2020 Víctor Aguilera Puerto
+// SPDX-FileCopyrightText: 2020 zumorica
+// SPDX-FileCopyrightText: 2021 20kdc
+// SPDX-FileCopyrightText: 2021 Acruid
+// SPDX-FileCopyrightText: 2021 DrSmugleaf
+// SPDX-FileCopyrightText: 2021 metalgearsloth
+// SPDX-FileCopyrightText: 2022 Leon Friedrich
+// SPDX-FileCopyrightText: 2022 Vera Aguilera Puerto
+// SPDX-FileCopyrightText: 2022 mirrorcult
+// SPDX-FileCopyrightText: 2023 Visne
+// SPDX-FileCopyrightText: 2024 Pieter-Jan Briers
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
+using System.IO;
using System.Linq;
using Content.Shared.CCVar;
using Content.Shared.Instruments;
@@ -12,7 +29,7 @@ using Robust.Shared.Timing;
namespace Content.Client.Instruments;
-public sealed class InstrumentSystem : SharedInstrumentSystem
+public sealed partial class InstrumentSystem : SharedInstrumentSystem
{
[Dependency] private readonly IClientNetManager _netManager = default!;
[Dependency] private readonly IMidiManager _midiManager = default!;
@@ -23,6 +40,8 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
public int MaxMidiEventsPerBatch { get; private set; }
public int MaxMidiEventsPerSecond { get; private set; }
+ public event Action? OnChannelsUpdated;
+
public override void Initialize()
{
base.Initialize();
@@ -38,6 +57,26 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
SubscribeLocalEvent(OnShutdown);
SubscribeLocalEvent(OnHandleState);
+ SubscribeLocalEvent(OnActiveInstrumentAfterHandleState);
+ }
+
+ private bool _isUpdateQueued = false;
+
+ private void OnActiveInstrumentAfterHandleState(Entity ent, ref AfterAutoHandleStateEvent args)
+ {
+ // Called in the update loop so that the components update client side for resolving them in TryComps.
+ _isUpdateQueued = true;
+ }
+
+ public override void FrameUpdate(float frameTime)
+ {
+ base.FrameUpdate(frameTime);
+
+ if (!_isUpdateQueued)
+ return;
+
+ _isUpdateQueued = false;
+ OnChannelsUpdated?.Invoke();
}
private void OnHandleState(EntityUid uid, SharedInstrumentComponent component, ref ComponentHandleState args)
@@ -167,11 +206,14 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
private void UpdateRendererMaster(InstrumentComponent instrument)
{
- if (instrument.Renderer == null || instrument.Master == null)
+ if (instrument.Renderer == null)
return;
- if (!TryComp(instrument.Master, out InstrumentComponent? masterInstrument) || masterInstrument.Renderer == null)
+ if (instrument.Master == null || !TryComp(instrument.Master, out InstrumentComponent? masterInstrument) || masterInstrument.Renderer == null)
+ {
+ instrument.Renderer.Master = null;
return;
+ }
instrument.Renderer.Master = masterInstrument.Renderer;
}
@@ -196,15 +238,16 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
return;
}
- instrument.Renderer?.SystemReset();
- instrument.Renderer?.ClearAllEvents();
+ if (instrument.Renderer is { } renderer)
+ {
+ renderer.Master = null;
+ renderer.SystemReset();
+ renderer.ClearAllEvents();
- var renderer = instrument.Renderer;
-
- // We dispose of the synth two seconds from now to allow the last notes to stop from playing.
- // Don't use timers bound to the entity in case it is getting deleted.
- if (renderer != null)
+ // We dispose of the synth two seconds from now to allow the last notes to stop from playing.
+ // Don't use timers bound to the entity in case it is getting deleted.
Timer.Spawn(2000, () => { renderer.Dispose(); });
+ }
instrument.Renderer = null;
instrument.MidiEventBuffer.Clear();
@@ -248,7 +291,13 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
}
+ [Obsolete("Use overload that takes in byte[] instead.")]
public bool OpenMidi(EntityUid uid, ReadOnlySpan data, InstrumentComponent? instrument = null)
+ {
+ return OpenMidi(uid, data.ToArray(), instrument);
+ }
+
+ public bool OpenMidi(EntityUid uid, byte[] data, InstrumentComponent? instrument = null)
{
if (!Resolve(uid, ref instrument))
return false;
@@ -259,7 +308,8 @@ public sealed class InstrumentSystem : SharedInstrumentSystem
return false;
SetMaster(uid, null);
- instrument.MidiEventBuffer.Clear();
+ TrySetChannels(uid, data);
+
instrument.Renderer.OnMidiEvent += instrument.MidiEventBuffer.Add;
return true;
}
diff --git a/Content.Client/Instruments/MidiParser/MidiInstrument.cs b/Content.Client/Instruments/MidiParser/MidiInstrument.cs
new file mode 100644
index 0000000000..bac24dd986
--- /dev/null
+++ b/Content.Client/Instruments/MidiParser/MidiInstrument.cs
@@ -0,0 +1,151 @@
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
+using Robust.Shared.Utility;
+
+namespace Content.Client.Instruments.MidiParser;
+
+// This file was autogenerated. Based on https://www.ccarh.org/courses/253/handout/gminstruments/
+public enum MidiInstrument : byte
+{
+ AcousticGrandPiano = 0,
+ BrightAcousticPiano = 1,
+ ElectricGrandPiano = 2,
+ HonkyTonkPiano = 3,
+ RhodesPiano = 4,
+ ChorusedPiano = 5,
+ Harpsichord = 6,
+ Clavinet = 7,
+ Celesta = 8,
+ Glockenspiel = 9,
+ MusicBox = 10,
+ Vibraphone = 11,
+ Marimba = 12,
+ Xylophone = 13,
+ TubularBells = 14,
+ Dulcimer = 15,
+ HammondOrgan = 16,
+ PercussiveOrgan = 17,
+ RockOrgan = 18,
+ ChurchOrgan = 19,
+ ReedOrgan = 20,
+ Accordion = 21,
+ Harmonica = 22,
+ TangoAccordion = 23,
+ AcousticNylonGuitar = 24,
+ AcousticSteelGuitar = 25,
+ ElectricJazzGuitar = 26,
+ ElectricCleanGuitar = 27,
+ ElectricMutedGuitar = 28,
+ OverdrivenGuitar = 29,
+ DistortionGuitar = 30,
+ GuitarHarmonics = 31,
+ AcousticBass = 32,
+ FingeredElectricBass = 33,
+ PluckedElectricBass = 34,
+ FretlessBass = 35,
+ SlapBass1 = 36,
+ SlapBass2 = 37,
+ SynthBass1 = 38,
+ SynthBass2 = 39,
+ Violin = 40,
+ Viola = 41,
+ Cello = 42,
+ Contrabass = 43,
+ TremoloStrings = 44,
+ PizzicatoStrings = 45,
+ OrchestralHarp = 46,
+ Timpani = 47,
+ StringEnsemble1 = 48,
+ StringEnsemble2 = 49,
+ SynthStrings1 = 50,
+ SynthStrings2 = 51,
+ ChoirAah = 52,
+ VoiceOoh = 53,
+ SynthChoir = 54,
+ OrchestraHit = 55,
+ Trumpet = 56,
+ Trombone = 57,
+ Tuba = 58,
+ MutedTrumpet = 59,
+ FrenchHorn = 60,
+ BrassSection = 61,
+ SynthBrass1 = 62,
+ SynthBrass2 = 63,
+ SopranoSax = 64,
+ AltoSax = 65,
+ TenorSax = 66,
+ BaritoneSax = 67,
+ Oboe = 68,
+ EnglishHorn = 69,
+ Bassoon = 70,
+ Clarinet = 71,
+ Piccolo = 72,
+ Flute = 73,
+ Recorder = 74,
+ PanFlute = 75,
+ BottleBlow = 76,
+ Shakuhachi = 77,
+ Whistle = 78,
+ Ocarina = 79,
+ SquareWaveLead = 80,
+ SawtoothWaveLead = 81,
+ CalliopeLead = 82,
+ ChiffLead = 83,
+ CharangLead = 84,
+ VoiceLead = 85,
+ FithsLead = 86,
+ BassLead = 87,
+ NewAgePad = 88,
+ WarmPad = 89,
+ PolysynthPad = 90,
+ ChoirPad = 91,
+ BowedPad = 92,
+ MetallicPad = 93,
+ HaloPad = 94,
+ SweepPad = 95,
+ RainEffect = 96,
+ SoundtrackEffect = 97,
+ CrystalEffect = 98,
+ AtmosphereEffect = 99,
+ BrightnessEffect = 100,
+ GoblinsEffect = 101,
+ EchoesEffect = 102,
+ SciFiEffect = 103,
+ Sitar = 104,
+ Banjo = 105,
+ Shamisen = 106,
+ Koto = 107,
+ Kalimba = 108,
+ Bagpipe = 109,
+ Fiddle = 110,
+ Shanai = 111,
+ TinkleBell = 112,
+ Agogo = 113,
+ SteelDrums = 114,
+ Woodblock = 115,
+ TaikoDrum = 116,
+ MelodicTom = 117,
+ SynthDrum = 118,
+ ReverseCymbal = 119,
+ GuitarFretNoise = 120,
+ BreathNoise = 121,
+ Seashore = 122,
+ BirdTweet = 123,
+ TelephoneRing = 124,
+ Helicopter = 125,
+ Applause = 126,
+ Gunshot = 127,
+}
+
+public static class MidiInstrumentExt
+{
+ ///
+ /// Turns the given enum value into it's string representation to be used in localization.
+ ///
+ public static string GetStringRep(this MidiInstrument instrument)
+ {
+ return CaseConversion.PascalToKebab(instrument.ToString());
+ }
+}
diff --git a/Content.Client/Instruments/MidiParser/MidiParser.cs b/Content.Client/Instruments/MidiParser/MidiParser.cs
new file mode 100644
index 0000000000..a809e05fdd
--- /dev/null
+++ b/Content.Client/Instruments/MidiParser/MidiParser.cs
@@ -0,0 +1,190 @@
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using Content.Shared.Instruments;
+
+namespace Content.Client.Instruments.MidiParser;
+
+public static class MidiParser
+{
+ // Thanks again to http://www.somascape.org/midi/tech/mfile.html
+ public static bool TryGetMidiTracks(
+ byte[] data,
+ [NotNullWhen(true)] out MidiTrack[]? tracks,
+ [NotNullWhen(false)] out string? error)
+ {
+ tracks = null;
+ error = null;
+
+ var stream = new MidiStreamWrapper(data);
+
+ if (stream.ReadString(4) != "MThd")
+ {
+ error = "Invalid file header";
+ return false;
+ }
+
+ var headerLength = stream.ReadUInt32();
+ // MIDI specs define that the header is 6 bytes, we only look at the 6 bytes, if its more, we skip ahead.
+
+ stream.Skip(2); // format
+ var trackCount = stream.ReadUInt16();
+ stream.Skip(2); // time div
+
+ // We now skip ahead if we still have any header length left
+ stream.Skip((int)(headerLength - 6));
+
+ var parsedTracks = new List();
+
+ for (var i = 0; i < trackCount; i++)
+ {
+ if (stream.ReadString(4) != "MTrk")
+ {
+ tracks = null;
+ error = "Track contains invalid header";
+ return false;
+ }
+
+ var track = new MidiTrack();
+
+ var trackLength = stream.ReadUInt32();
+ var trackEnd = stream.StreamPosition + trackLength;
+ var hasMidiEvent = false;
+ byte? lastStatusByte = null;
+
+ while (stream.StreamPosition < trackEnd)
+ {
+ stream.ReadVariableLengthQuantity();
+
+ /*
+ * If the first (status) byte is less than 128 (hex 80), this implies that running status is in effect,
+ * and that this byte is actually the first data byte (the status carrying over from the previous MIDI event).
+ * This can only be the case if the immediately previous event was also a MIDI event,
+ * i.e. SysEx and Meta events interrupt (clear) running status.
+ * See http://www.somascape.org/midi/tech/mfile.html#events
+ */
+
+ var firstByte = stream.ReadByte();
+ if (firstByte >= 0x80)
+ {
+ lastStatusByte = firstByte;
+ }
+ else
+ {
+ // Running status: push byte back for reading as data
+ stream.Skip(-1);
+ }
+
+ // The first event in each MTrk chunk must specify status.
+ if (lastStatusByte == null)
+ {
+ tracks = null;
+ error = "Track data not valid, expected status byte, got nothing.";
+ return false;
+ }
+
+ var eventType = (byte)(lastStatusByte & 0xF0);
+
+ switch (lastStatusByte)
+ {
+ // Meta events
+ case 0xFF:
+ {
+ var metaType = stream.ReadByte();
+ var metaLength = stream.ReadVariableLengthQuantity();
+ var metaData = stream.ReadBytes((int)metaLength);
+ if (metaType == 0x00) // SequenceNumber event
+ continue;
+
+ // Meta event types 01 through 0F are reserved for text and all follow the basic FF 01 len text format
+ if (metaType is < 0x01 or > 0x0F)
+ break;
+
+ // 0x03 is TrackName,
+ // 0x04 is InstrumentName
+
+ // This string can potentially contain control characters, including 0x00 which can cause problems if it ends up in database entries via admin logs
+ // we sanitize TrackName and InstrumentName after they have been send to the server
+ var text = Encoding.ASCII.GetString(metaData, 0, (int)metaLength);
+ switch (metaType)
+ {
+ case 0x03 when track.TrackName == null:
+ track.TrackName = text;
+ break;
+ case 0x04 when track.InstrumentName == null:
+ track.InstrumentName = text;
+ break;
+ }
+
+ // still here? then we dont care about the event
+ break;
+ }
+
+ // SysEx events
+ case 0xF0:
+ case 0xF7:
+ {
+ var sysexLength = stream.ReadVariableLengthQuantity();
+ stream.Skip((int)sysexLength);
+ // Sysex events and meta-events cancel any running status which was in effect.
+ // Running status does not apply to and may not be used for these messages.
+ lastStatusByte = null;
+ break;
+ }
+
+
+ default:
+ switch (eventType)
+ {
+ // Program Change
+ case 0xC0:
+ {
+ var programNumber = stream.ReadByte();
+ if (track.ProgramName == null)
+ {
+ if (programNumber < Enum.GetValues().Length)
+ track.ProgramName = Loc.GetString($"instruments-component-menu-midi-channel-{((MidiInstrument)programNumber).GetStringRep()}");
+ }
+ break;
+ }
+
+ case 0x80: // Note Off
+ case 0x90: // Note On
+ case 0xA0: // Polyphonic Key Pressure
+ case 0xB0: // Control Change
+ case 0xE0: // Pitch Bend
+ {
+ hasMidiEvent = true;
+ stream.Skip(2);
+ break;
+ }
+
+ case 0xD0: // Channel Pressure
+ {
+ hasMidiEvent = true;
+ stream.Skip(1);
+ break;
+ }
+
+ default:
+ error = $"Unknown MIDI event type {lastStatusByte:X2}";
+ tracks = null;
+ return false;
+ }
+ break;
+ }
+ }
+
+
+ if (hasMidiEvent)
+ parsedTracks.Add(track);
+ }
+
+ tracks = parsedTracks.ToArray();
+
+ return true;
+ }
+}
diff --git a/Content.Client/Instruments/MidiParser/MidiStreamWrapper.cs b/Content.Client/Instruments/MidiParser/MidiStreamWrapper.cs
new file mode 100644
index 0000000000..409c245726
--- /dev/null
+++ b/Content.Client/Instruments/MidiParser/MidiStreamWrapper.cs
@@ -0,0 +1,107 @@
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
+using System.IO;
+using System.Text;
+
+namespace Content.Client.Instruments.MidiParser;
+
+public sealed class MidiStreamWrapper
+{
+ private readonly MemoryStream _stream;
+ private byte[] _buffer;
+
+ public long StreamPosition => _stream.Position;
+
+ public MidiStreamWrapper(byte[] data)
+ {
+ _stream = new MemoryStream(data, writable: false);
+ _buffer = new byte[4];
+ }
+
+ ///
+ /// Skips X number of bytes in the stream.
+ ///
+ /// The number of bytes to skip. If 0, no operations on the stream are performed.
+ public void Skip(int count)
+ {
+ if (count == 0)
+ return;
+
+ _stream.Seek(count, SeekOrigin.Current);
+ }
+
+ public byte ReadByte()
+ {
+ var b = _stream.ReadByte();
+ if (b == -1)
+ throw new Exception("Unexpected end of stream");
+
+ return (byte)b;
+ }
+
+ ///
+ /// Reads N bytes using the buffer.
+ ///
+ public byte[] ReadBytes(int count)
+ {
+ if (_buffer.Length < count)
+ {
+ Array.Resize(ref _buffer, count);
+ }
+
+ var read = _stream.Read(_buffer, 0, count);
+ if (read != count)
+ throw new Exception("Unexpected end of stream");
+
+ return _buffer;
+ }
+
+ ///
+ /// Reads a 4 byte big-endian uint.
+ ///
+ public uint ReadUInt32()
+ {
+ var bytes = ReadBytes(4);
+ return (uint)((bytes[0] << 24) |
+ (bytes[1] << 16) |
+ (bytes[2] << 8) |
+ (bytes[3]));
+ }
+
+ ///
+ /// Reads a 2 byte big-endian ushort.
+ ///
+ public ushort ReadUInt16()
+ {
+ var bytes = ReadBytes(2);
+ return (ushort)((bytes[0] << 8) | bytes[1]);
+ }
+
+ public string ReadString(int count)
+ {
+ var bytes = ReadBytes(count);
+ return Encoding.UTF8.GetString(bytes, 0, count);
+ }
+
+ public uint ReadVariableLengthQuantity()
+ {
+ uint value = 0;
+
+ // variable-length-quantities encode ints using 7 bits per byte
+ // the highest bit (7) is used for a continuation flag. We read until the high bit is 0
+
+ while (true)
+ {
+ var b = ReadByte();
+ value = (value << 7) | (uint)(b & 0x7f); // Shift current value and add 7 bits
+ // value << 7, make room for the next 7 bits
+ // b & 0x7F mask out the high bit to just get the 7 bit payload
+ if ((b & 0x80) == 0)
+ break; // This was the last bit.
+ }
+
+ return value;
+ }
+}
diff --git a/Content.Client/Instruments/UI/ChannelsMenu.xaml b/Content.Client/Instruments/UI/ChannelsMenu.xaml
index 1bf4647609..20e4a3e923 100644
--- a/Content.Client/Instruments/UI/ChannelsMenu.xaml
+++ b/Content.Client/Instruments/UI/ChannelsMenu.xaml
@@ -7,5 +7,7 @@
+
diff --git a/Content.Client/Instruments/UI/ChannelsMenu.xaml.cs b/Content.Client/Instruments/UI/ChannelsMenu.xaml.cs
index c175e67842..d8ca24585e 100644
--- a/Content.Client/Instruments/UI/ChannelsMenu.xaml.cs
+++ b/Content.Client/Instruments/UI/ChannelsMenu.xaml.cs
@@ -1,26 +1,63 @@
+// SPDX-FileCopyrightText: 2023 Vera Aguilera Puerto
+// SPDX-FileCopyrightText: 2024 Nemanja
+// SPDX-FileCopyrightText: 2024 metalgearsloth
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
+using Content.Shared.Instruments;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Audio.Midi;
-using Robust.Shared.Timing;
+using Robust.Shared.Utility;
namespace Content.Client.Instruments.UI;
[GenerateTypedNameReferences]
public sealed partial class ChannelsMenu : DefaultWindow
{
+ [Dependency] private readonly IEntityManager _entityManager = null!;
+
private readonly InstrumentBoundUserInterface _owner;
public ChannelsMenu(InstrumentBoundUserInterface owner) : base()
{
RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
_owner = owner;
ChannelList.OnItemSelected += OnItemSelected;
ChannelList.OnItemDeselected += OnItemDeselected;
AllButton.OnPressed += OnAllPressed;
ClearButton.OnPressed += OnClearPressed;
+ DisplayTrackNames.OnPressed += OnDisplayTrackNamesPressed;
+ }
+
+ protected override void EnteredTree()
+ {
+ base.EnteredTree();
+
+ _owner.Instruments.OnChannelsUpdated += UpdateChannelList;
+ }
+
+ private void OnDisplayTrackNamesPressed(BaseButton.ButtonEventArgs obj)
+ {
+ DisplayTrackNames.SetClickPressed(!DisplayTrackNames.Pressed);
+ Populate();
+ }
+
+ private void UpdateChannelList()
+ {
+ Populate(); // This is kind of in-efficent because we don't filter for which instrument updated its channels, but idc
+ }
+
+ protected override void ExitedTree()
+ {
+ base.ExitedTree();
+
+ _owner.Instruments.OnChannelsUpdated -= UpdateChannelList;
}
private void OnItemSelected(ItemList.ItemListSelectedEventArgs args)
@@ -51,15 +88,71 @@ public sealed partial class ChannelsMenu : DefaultWindow
}
}
- public void Populate(InstrumentComponent? instrument)
+ ///
+ /// Walks up the tree of instrument masters to find the truest master of them all.
+ ///
+ private ActiveInstrumentComponent ResolveActiveInstrument(InstrumentComponent? comp)
+ {
+ comp ??= _entityManager.GetComponent(_owner.Owner);
+
+ var instrument = new Entity(_owner.Owner, comp);
+
+ while (true)
+ {
+ if (instrument.Comp.Master == null)
+ break;
+
+ instrument = new Entity((EntityUid)instrument.Comp.Master,
+ _entityManager.GetComponent((EntityUid)instrument.Comp.Master));
+ }
+
+ return _entityManager.GetComponent(instrument.Owner);
+ }
+
+ public void Populate()
{
ChannelList.Clear();
+ var instrument = _entityManager.GetComponent(_owner.Owner);
+ var activeInstrument = ResolveActiveInstrument(instrument);
for (int i = 0; i < RobustMidiEvent.MaxChannels; i++)
{
- var item = ChannelList.AddItem(_owner.Loc.GetString("instrument-component-channel-name",
- ("number", i)), null, true, i);
+ var label = _owner.Loc.GetString("instrument-component-channel-name",
+ ("number", i));
+ if (activeInstrument != null
+ && activeInstrument.Tracks.TryGetValue(i, out var resolvedMidiChannel)
+ && resolvedMidiChannel != null)
+ {
+ if (DisplayTrackNames.Pressed)
+ {
+ label = resolvedMidiChannel switch
+ {
+ { TrackName: not null, InstrumentName: not null } =>
+ Loc.GetString("instruments-component-channels-multi",
+ ("channel", i),
+ ("name", resolvedMidiChannel.TrackName),
+ ("other", resolvedMidiChannel.InstrumentName)),
+ { TrackName: not null } =>
+ Loc.GetString("instruments-component-channels-single",
+ ("channel", i),
+ ("name", resolvedMidiChannel.TrackName)),
+ _ => label,
+ };
+ }
+ else
+ {
+ label = resolvedMidiChannel switch
+ {
+ { ProgramName: not null } =>
+ Loc.GetString("instruments-component-channels-single",
+ ("channel", i),
+ ("name", resolvedMidiChannel.ProgramName)),
+ _ => label,
+ };
+ }
+ }
+ var item = ChannelList.AddItem(label, null, true, i);
item.Selected = !instrument?.FilteredChannels[i] ?? false;
}
diff --git a/Content.Client/Instruments/UI/InstrumentBoundUserInterface.cs b/Content.Client/Instruments/UI/InstrumentBoundUserInterface.cs
index e511cb8654..b8bf74c929 100644
--- a/Content.Client/Instruments/UI/InstrumentBoundUserInterface.cs
+++ b/Content.Client/Instruments/UI/InstrumentBoundUserInterface.cs
@@ -1,4 +1,20 @@
+// SPDX-FileCopyrightText: 2019 Víctor Aguilera Puerto
+// SPDX-FileCopyrightText: 2021 Acruid
+// SPDX-FileCopyrightText: 2021 Vera Aguilera Puerto
+// SPDX-FileCopyrightText: 2022 mirrorcult
+// SPDX-FileCopyrightText: 2023 DrSmugleaf
+// SPDX-FileCopyrightText: 2023 TemporalOroboros
+// SPDX-FileCopyrightText: 2024 Leon Friedrich
+// SPDX-FileCopyrightText: 2024 Nemanja
+// SPDX-FileCopyrightText: 2024 metalgearsloth
+// SPDX-FileCopyrightText: 2025 Milon
+// SPDX-FileCopyrightText: 2025 SpaceManiac
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
using Content.Shared.ActionBlocker;
+using Content.Shared.Instruments;
using Content.Shared.Instruments.UI;
using Content.Shared.Interaction;
using Robust.Client.Audio.Midi;
@@ -101,9 +117,7 @@ namespace Content.Client.Instruments.UI
public void OpenChannelsMenu()
{
_channelsMenu ??= new ChannelsMenu(this);
- EntMan.TryGetComponent(Owner, out InstrumentComponent? instrument);
-
- _channelsMenu.Populate(instrument);
+ _channelsMenu.Populate();
_channelsMenu.OpenCenteredRight();
}
diff --git a/Content.Client/Instruments/UI/InstrumentMenu.xaml.cs b/Content.Client/Instruments/UI/InstrumentMenu.xaml.cs
index 9b14e01fb5..8d429a523b 100644
--- a/Content.Client/Instruments/UI/InstrumentMenu.xaml.cs
+++ b/Content.Client/Instruments/UI/InstrumentMenu.xaml.cs
@@ -1,3 +1,26 @@
+// SPDX-FileCopyrightText: 2019 Víctor Aguilera Puerto
+// SPDX-FileCopyrightText: 2020 DTanxxx
+// SPDX-FileCopyrightText: 2020 Radrark
+// SPDX-FileCopyrightText: 2020 Radrark
+// SPDX-FileCopyrightText: 2020 ShadowCommander
+// SPDX-FileCopyrightText: 2021 Acruid
+// SPDX-FileCopyrightText: 2021 DrSmugleaf
+// SPDX-FileCopyrightText: 2021 Vera Aguilera Puerto
+// SPDX-FileCopyrightText: 2021 metalgearsloth
+// SPDX-FileCopyrightText: 2022 Andreas Kämper
+// SPDX-FileCopyrightText: 2022 Leon Friedrich
+// SPDX-FileCopyrightText: 2022 Paul Ritter
+// SPDX-FileCopyrightText: 2022 mirrorcult
+// SPDX-FileCopyrightText: 2022 wrexbe
+// SPDX-FileCopyrightText: 2023 Kara
+// SPDX-FileCopyrightText: 2024 Hannah Giovanna Dawson
+// SPDX-FileCopyrightText: 2024 Nemanja
+// SPDX-FileCopyrightText: 2024 Pieter-Jan Briers
+// SPDX-FileCopyrightText: 2024 slarticodefast
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
using System.IO;
using System.Numerics;
using System.Threading.Tasks;
@@ -11,6 +34,7 @@ using Robust.Client.UserInterface.XAML;
using Robust.Shared.Containers;
using Robust.Shared.Input;
using Robust.Shared.Timing;
+using Robust.Shared.Utility;
using static Robust.Client.UserInterface.Controls.BaseButton;
using Range = Robust.Client.UserInterface.Controls.Range;
@@ -128,7 +152,7 @@ namespace Content.Client.Instruments.UI
// or focus the previously-opened window.
_isMidiFileDialogueWindowOpen = true;
- await using var file = await _dialogs.OpenFile(filters);
+ await using var file = await _dialogs.OpenFile(filters, FileAccess.Read);
_isMidiFileDialogueWindowOpen = false;
@@ -145,10 +169,6 @@ namespace Content.Client.Instruments.UI
if (!PlayCheck())
return;
- await using var memStream = new MemoryStream((int) file.Length);
-
- await file.CopyToAsync(memStream);
-
if (!_entManager.TryGetComponent(Entity, out var instrument))
{
return;
@@ -156,7 +176,7 @@ namespace Content.Client.Instruments.UI
if (!_entManager.System()
.OpenMidi(Entity,
- memStream.GetBuffer().AsSpan(0, (int) memStream.Length),
+ file.CopyToArray(),
instrument))
{
return;
diff --git a/Content.Server/Instruments/InstrumentComponent.cs b/Content.Server/Instruments/InstrumentComponent.cs
index db9dbb375b..2b92ef91ee 100644
--- a/Content.Server/Instruments/InstrumentComponent.cs
+++ b/Content.Server/Instruments/InstrumentComponent.cs
@@ -1,4 +1,29 @@
-using Content.Server.UserInterface;
+// SPDX-FileCopyrightText: 2020 Jackson Lewis
+// SPDX-FileCopyrightText: 2020 Pieter-Jan Briers
+// SPDX-FileCopyrightText: 2020 Tyler Young
+// SPDX-FileCopyrightText: 2020 Vera Aguilera Puerto
+// SPDX-FileCopyrightText: 2020 Víctor Aguilera Puerto
+// SPDX-FileCopyrightText: 2020 chairbender
+// SPDX-FileCopyrightText: 2020 derek
+// SPDX-FileCopyrightText: 2020 zumorica
+// SPDX-FileCopyrightText: 2021 20kdc
+// SPDX-FileCopyrightText: 2021 Acruid
+// SPDX-FileCopyrightText: 2021 Galactic Chimp
+// SPDX-FileCopyrightText: 2021 Paul
+// SPDX-FileCopyrightText: 2021 Paul Ritter
+// SPDX-FileCopyrightText: 2021 Visne
+// SPDX-FileCopyrightText: 2021 mirrorcult
+// SPDX-FileCopyrightText: 2022 wrexbe
+// SPDX-FileCopyrightText: 2023 Checkraze
+// SPDX-FileCopyrightText: 2023 DrSmugleaf
+// SPDX-FileCopyrightText: 2023 Kara
+// SPDX-FileCopyrightText: 2023 Leon Friedrich
+// SPDX-FileCopyrightText: 2024 Jake Huxell
+// SPDX-FileCopyrightText: 2024 metalgearsloth
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
using Content.Shared.Instruments;
using Robust.Shared.Player;
using ActivatableUIComponent = Content.Shared.UserInterface.ActivatableUIComponent;
@@ -21,8 +46,3 @@ public sealed partial class InstrumentComponent : SharedInstrumentComponent
_entMan.GetComponentOrNull(Owner)?.CurrentSingleUser
?? _entMan.GetComponentOrNull(Owner)?.PlayerSession.AttachedEntity;
}
-
-[RegisterComponent]
-public sealed partial class ActiveInstrumentComponent : Component
-{
-}
diff --git a/Content.Server/Instruments/InstrumentSystem.cs b/Content.Server/Instruments/InstrumentSystem.cs
index 12bd968704..f267654cc4 100644
--- a/Content.Server/Instruments/InstrumentSystem.cs
+++ b/Content.Server/Instruments/InstrumentSystem.cs
@@ -18,15 +18,20 @@
// SPDX-FileCopyrightText: 2024 Cojoke
// SPDX-FileCopyrightText: 2024 Pieter-Jan Briers
// SPDX-FileCopyrightText: 2024 Plykiya
-// SPDX-FileCopyrightText: 2025 J
+// SPDX-FileCopyrightText: 2025 Redrover1760
+// SPDX-FileCopyrightText: 2025 bitcrushing
//
-// SPDX-License-Identifier: AGPL-3.0-or-later
+// SPDX-License-Identifier: MPL-2.0
+using System.Linq;
using Content.Server.Administration;
+using Content.Server.Administration.Logs;
using Content.Server.Interaction;
using Content.Server.Popups;
using Content.Server.Stunnable;
using Content.Shared.Administration;
+using Content.Shared.CCVar;
+using Content.Shared.Database;
using Content.Shared.Examine;
using Content.Shared.Instruments;
using Content.Shared.Instruments.UI;
@@ -41,6 +46,7 @@ using Robust.Shared.Console;
using Robust.Shared.GameStates;
using Robust.Shared.Player;
using Robust.Shared.Timing;
+using Robust.Shared.Utility;
namespace Content.Server.Instruments;
@@ -55,6 +61,7 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
[Dependency] private readonly PopupSystem _popup = default!;
[Dependency] private readonly TransformSystem _transform = default!;
[Dependency] private readonly ExamineSystemShared _examineSystem = default!;
+ [Dependency] private readonly IAdminLogManager _admingLogSystem = default!;
private const float MaxInstrumentBandRange = 10f;
@@ -74,6 +81,7 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
SubscribeNetworkEvent(OnMidiStop);
SubscribeNetworkEvent(OnMidiSetMaster);
SubscribeNetworkEvent(OnMidiSetFilteredChannel);
+ SubscribeNetworkEvent(OnMidiSetChannels);
Subs.BuiEvents(InstrumentUiKey.Key, subs =>
{
@@ -156,6 +164,47 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
Clean(uid, instrument);
}
+
+ private void OnMidiSetChannels(InstrumentSetChannelsEvent msg, EntitySessionEventArgs args)
+ {
+ var uid = GetEntity(msg.Uid);
+
+ if (!TryComp(uid, out InstrumentComponent? instrument) || !TryComp(uid, out ActiveInstrumentComponent? activeInstrument))
+ return;
+
+ if (args.SenderSession.AttachedEntity != instrument.InstrumentPlayer)
+ return;
+
+ if (msg.Tracks.Length > RobustMidiEvent.MaxChannels)
+ {
+ Log.Warning($"{args.SenderSession.UserId.ToString()} - Tried to send tracks over the limit! Received: {msg.Tracks.Length}; Limit: {RobustMidiEvent.MaxChannels}");
+ return;
+ }
+
+
+ foreach (var t in msg.Tracks)
+ {
+ // Remove any control characters that may be part of the midi file so they don't end up in the admin logs.
+ t?.SanitizeFields();
+ // Truncate any track names too long.
+ t?.TruncateFields(_cfg.GetCVar(CCVars.MidiMaxChannelNameLength));
+ }
+
+ var tracksString = string.Join("\n",
+ msg.Tracks
+ .Where(t => t != null)
+ .Select(t => t!.ToString()));
+
+ _admingLogSystem.Add(
+ LogType.Instrument,
+ LogImpact.Low,
+ $"{ToPrettyString(args.SenderSession.AttachedEntity)} set the midi channels for {ToPrettyString(uid)} to {tracksString}");
+
+ activeInstrument.Tracks = msg.Tracks;
+
+ Dirty(uid, activeInstrument);
+ }
+
private void OnMidiSetMaster(InstrumentSetMasterEvent msg, EntitySessionEventArgs args)
{
var uid = GetEntity(msg.Uid);
@@ -247,20 +296,20 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
public (NetEntity, string)[] GetBands(EntityUid uid)
{
- var metadataQuery = EntityManager.GetEntityQuery();
+ var metadataQuery = GetEntityQuery();
if (Deleted(uid))
return Array.Empty<(NetEntity, string)>();
var list = new ValueList<(NetEntity, string)>();
- var instrumentQuery = EntityManager.GetEntityQuery();
+ var instrumentQuery = GetEntityQuery();
if (!TryComp(uid, out InstrumentComponent? originInstrument)
|| originInstrument.InstrumentPlayer is not {} originPlayer)
return Array.Empty<(NetEntity, string)>();
// It's probably faster to get all possible active instruments than all entities in range
- var activeEnumerator = EntityManager.EntityQueryEnumerator();
+ var activeEnumerator = EntityQueryEnumerator();
while (activeEnumerator.MoveNext(out var entity, out _))
{
if (entity == uid)
@@ -403,8 +452,8 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
_bandRequestQueue.Clear();
}
- var activeQuery = EntityManager.GetEntityQuery();
- var transformQuery = EntityManager.GetEntityQuery();
+ var activeQuery = GetEntityQuery();
+ var transformQuery = GetEntityQuery();
var query = AllEntityQuery();
while (query.MoveNext(out var uid, out _, out var instrument))
@@ -437,7 +486,7 @@ public sealed partial class InstrumentSystem : SharedInstrumentSystem
{
if (instrument.InstrumentPlayer is {Valid: true} mob)
{
- _stuns.TryParalyze(mob, TimeSpan.FromSeconds(1), true);
+ _stuns.TryParalyze(mob, TimeSpan.FromSeconds(1), true); // Mono - Wizden does TryUpdateParalyzeDuration here
_popup.PopupEntity(Loc.GetString("instrument-component-finger-cramps-max-message"),
uid, mob, PopupType.LargeCaution);
diff --git a/Content.Server/Instruments/SwappableInstrumentComponent.cs b/Content.Server/Instruments/SwappableInstrumentComponent.cs
index 6acf43151d..9aae5c12e9 100644
--- a/Content.Server/Instruments/SwappableInstrumentComponent.cs
+++ b/Content.Server/Instruments/SwappableInstrumentComponent.cs
@@ -1,3 +1,10 @@
+// SPDX-FileCopyrightText: 2022 EmoGarbage404
+// SPDX-FileCopyrightText: 2023 DrSmugleaf
+// SPDX-FileCopyrightText: 2024 Whatstone
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
namespace Content.Server.Instruments;
[RegisterComponent]
@@ -11,11 +18,4 @@ public sealed partial class SwappableInstrumentComponent : Component
///
[DataField("instrumentList", required: true)]
public Dictionary InstrumentList = new();
-
- // Frontier: harpy instruments
- ///
- /// When true, only the instrument entity itself can swap its sound.
- ///
- [DataField]
- public bool OnlySetBySelf;
}
diff --git a/Content.Server/Instruments/SwappableInstrumentSystem.cs b/Content.Server/Instruments/SwappableInstrumentSystem.cs
index 81b3b2c100..b92c21a098 100644
--- a/Content.Server/Instruments/SwappableInstrumentSystem.cs
+++ b/Content.Server/Instruments/SwappableInstrumentSystem.cs
@@ -1,3 +1,13 @@
+// SPDX-FileCopyrightText: 2022 EmoGarbage404
+// SPDX-FileCopyrightText: 2022 Leon Friedrich
+// SPDX-FileCopyrightText: 2022 Vera Aguilera Puerto
+// SPDX-FileCopyrightText: 2023 metalgearsloth
+// SPDX-FileCopyrightText: 2024 Tayrtahn
+// SPDX-FileCopyrightText: 2024 Whatstone
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
using Content.Shared.Instruments;
using Content.Shared.Popups;
using Content.Shared.Verbs;
@@ -25,9 +35,6 @@ public sealed class SwappableInstrumentSystem : EntitySystem
if (!TryComp(uid, out var instrument))
return;
- if (component.OnlySetBySelf && uid != args.User) // Frontier: restrict instrument changes
- return; // Frontier: restrict instrument changes
-
var priority = 0;
foreach (var entry in component.InstrumentList)
{
diff --git a/Content.Server/PAI/PAISystem.cs b/Content.Server/PAI/PAISystem.cs
index bef3971d3c..fab274b1e5 100644
--- a/Content.Server/PAI/PAISystem.cs
+++ b/Content.Server/PAI/PAISystem.cs
@@ -1,7 +1,32 @@
+// SPDX-FileCopyrightText: 2021 20kdc
+// SPDX-FileCopyrightText: 2021 Acruid
+// SPDX-FileCopyrightText: 2021 Paul
+// SPDX-FileCopyrightText: 2021 Vera Aguilera Puerto
+// SPDX-FileCopyrightText: 2021 hubismal
+// SPDX-FileCopyrightText: 2022 Kara
+// SPDX-FileCopyrightText: 2022 mirrorcult
+// SPDX-FileCopyrightText: 2022 wrexbe
+// SPDX-FileCopyrightText: 2023 Checkraze
+// SPDX-FileCopyrightText: 2023 DrSmugleaf
+// SPDX-FileCopyrightText: 2023 Nemanja
+// SPDX-FileCopyrightText: 2023 ShadowCommander
+// SPDX-FileCopyrightText: 2023 Visne
+// SPDX-FileCopyrightText: 2023 deltanedas
+// SPDX-FileCopyrightText: 2023 deltanedas <@deltanedas:kde.org>
+// SPDX-FileCopyrightText: 2023 faint
+// SPDX-FileCopyrightText: 2024 Leon Friedrich
+// SPDX-FileCopyrightText: 2024 Whatstone
+// SPDX-FileCopyrightText: 2024 metalgearsloth
+// SPDX-FileCopyrightText: 2025 bitcrushing
+// SPDX-FileCopyrightText: 2025 slarticodefast
+//
+// SPDX-License-Identifier: MPL-2.0
+
using Content.Server.Ghost.Roles;
using Content.Server.Ghost.Roles.Components;
using Content.Server.Instruments;
using Content.Server.Kitchen.Components;
+using Content.Shared.Instruments;
using Content.Shared.Interaction.Events;
using Content.Shared.Mind.Components;
using Content.Shared.PAI;
diff --git a/Content.Server/Xenoarchaeology/XenoArtifacts/Triggers/Systems/ArtifactMusicTriggerSystem.cs b/Content.Server/Xenoarchaeology/XenoArtifacts/Triggers/Systems/ArtifactMusicTriggerSystem.cs
index c62ed58752..4e698e565e 100644
--- a/Content.Server/Xenoarchaeology/XenoArtifacts/Triggers/Systems/ArtifactMusicTriggerSystem.cs
+++ b/Content.Server/Xenoarchaeology/XenoArtifacts/Triggers/Systems/ArtifactMusicTriggerSystem.cs
@@ -1,6 +1,13 @@
-using System.Linq;
+// SPDX-FileCopyrightText: 2022 Nemanja
+// SPDX-FileCopyrightText: 2023 DrSmugleaf
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
+using System.Linq;
using Content.Server.Instruments;
using Content.Server.Xenoarchaeology.XenoArtifacts.Triggers.Components;
+using Content.Shared.Instruments;
namespace Content.Server.Xenoarchaeology.XenoArtifacts.Triggers.Systems;
diff --git a/Content.Shared.Database/LogType.cs b/Content.Shared.Database/LogType.cs
index 50f9f81a0b..22fbcfe8ee 100644
--- a/Content.Shared.Database/LogType.cs
+++ b/Content.Shared.Database/LogType.cs
@@ -1,3 +1,41 @@
+// SPDX-FileCopyrightText: 2021 Javier Guardia Fernández
+// SPDX-FileCopyrightText: 2021 Paul
+// SPDX-FileCopyrightText: 2021 Pieter-Jan Briers
+// SPDX-FileCopyrightText: 2021 Vera Aguilera Puerto
+// SPDX-FileCopyrightText: 2021 mirrorcult
+// SPDX-FileCopyrightText: 2022 Jacob Tong
+// SPDX-FileCopyrightText: 2022 Júlio César Ueti
+// SPDX-FileCopyrightText: 2022 Kara
+// SPDX-FileCopyrightText: 2022 Moony
+// SPDX-FileCopyrightText: 2022 Rane
+// SPDX-FileCopyrightText: 2022 ShadowCommander
+// SPDX-FileCopyrightText: 2022 metalgearsloth
+// SPDX-FileCopyrightText: 2023 Chief-Engineer
+// SPDX-FileCopyrightText: 2023 Debug
+// SPDX-FileCopyrightText: 2023 DrSmugleaf
+// SPDX-FileCopyrightText: 2023 Dvir
+// SPDX-FileCopyrightText: 2023 HerCoyote23
+// SPDX-FileCopyrightText: 2023 Nemanja
+// SPDX-FileCopyrightText: 2024 Alice "Arimah" Heurlin
+// SPDX-FileCopyrightText: 2024 GreaseMonk
+// SPDX-FileCopyrightText: 2024 Leon Friedrich
+// SPDX-FileCopyrightText: 2024 Trevor Day
+// SPDX-FileCopyrightText: 2024 TsjipTsjip
+// SPDX-FileCopyrightText: 2024 Winkarst
+// SPDX-FileCopyrightText: 2024 keronshb
+// SPDX-FileCopyrightText: 2024 nikthechampiongr
+// SPDX-FileCopyrightText: 2025 Ark
+// SPDX-FileCopyrightText: 2025 Checkraze
+// SPDX-FileCopyrightText: 2025 Dylan Hunter Whittingham
+// SPDX-FileCopyrightText: 2025 Ilya246
+// SPDX-FileCopyrightText: 2025 Palladinium
+// SPDX-FileCopyrightText: 2025 Redrover1760
+// SPDX-FileCopyrightText: 2025 Simon
+// SPDX-FileCopyrightText: 2025 Whatstone
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
namespace Content.Shared.Database;
// DO NOT CHANGE THE NUMERIC VALUES OF THESE
@@ -480,5 +518,10 @@ public enum LogType
///
/// Damaging grid collision has occurred.
///
- ShuttleImpact = 102
+ ShuttleImpact = 102,
+
+ ///
+ /// Events relating to midi playback.
+ ///
+ Instrument = 103,
}
diff --git a/Content.Shared/CCVar/CCVars.Midi.cs b/Content.Shared/CCVar/CCVars.Midi.cs
index 4ca4bfd6f8..d6dc9746ea 100644
--- a/Content.Shared/CCVar/CCVars.Midi.cs
+++ b/Content.Shared/CCVar/CCVars.Midi.cs
@@ -1,4 +1,9 @@
-using Robust.Shared.Configuration;
+// SPDX-FileCopyrightText: 2024 Simon
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
+using Robust.Shared.Configuration;
namespace Content.Shared.CCVar;
@@ -15,4 +20,10 @@ public sealed partial class CCVars
public static readonly CVarDef MaxMidiLaggedBatches =
CVarDef.Create("midi.max_lagged_batches", 8, CVar.SERVERONLY);
+
+ ///
+ /// Defines the max amount of characters to allow in the "Midi channel selector".
+ ///
+ public static readonly CVarDef MidiMaxChannelNameLength =
+ CVarDef.Create("midi.max_channel_name_length", 64, CVar.SERVERONLY);
}
diff --git a/Content.Shared/Instruments/SharedInstrumentComponent.cs b/Content.Shared/Instruments/SharedInstrumentComponent.cs
index 73500f3869..c32373d5fb 100644
--- a/Content.Shared/Instruments/SharedInstrumentComponent.cs
+++ b/Content.Shared/Instruments/SharedInstrumentComponent.cs
@@ -1,4 +1,20 @@
+// SPDX-FileCopyrightText: 2020 Tyler Young
+// SPDX-FileCopyrightText: 2020 Vera Aguilera Puerto
+// SPDX-FileCopyrightText: 2020 Víctor Aguilera Puerto
+// SPDX-FileCopyrightText: 2020 zumorica
+// SPDX-FileCopyrightText: 2021 Acruid
+// SPDX-FileCopyrightText: 2021 DrSmugleaf
+// SPDX-FileCopyrightText: 2021 Visne
+// SPDX-FileCopyrightText: 2022 Pieter-Jan Briers
+// SPDX-FileCopyrightText: 2022 mirrorcult
+// SPDX-FileCopyrightText: 2022 wrexbe
+// SPDX-FileCopyrightText: 2023 metalgearsloth
+// SPDX-FileCopyrightText: 2025 bitcrushing
+//
+// SPDX-License-Identifier: MPL-2.0
+
using System.Collections;
+using System.Text;
using Robust.Shared.Audio.Midi;
using Robust.Shared.GameStates;
using Robust.Shared.Serialization;
@@ -34,6 +50,18 @@ public abstract partial class SharedInstrumentComponent : Component
public BitArray FilteredChannels { get; set; } = new(RobustMidiEvent.MaxChannels, true);
}
+///
+/// Component that indicates that musical instrument was activated (ui opened).
+///
+[RegisterComponent, NetworkedComponent]
+[AutoGenerateComponentState(true)]
+public sealed partial class ActiveInstrumentComponent : Component
+{
+ [DataField]
+ [AutoNetworkedField]
+ public MidiTrack?[] Tracks = [];
+}
+
[Serializable, NetSerializable]
public sealed class InstrumentComponentState : ComponentState
{
@@ -138,3 +166,97 @@ public enum InstrumentUiKey
{
Key,
}
+
+///
+/// Sets the MIDI channels on an instrument.
+///
+[Serializable, NetSerializable]
+public sealed class InstrumentSetChannelsEvent : EntityEventArgs
+{
+ public NetEntity Uid { get; }
+ public MidiTrack?[] Tracks { get; set; }
+
+ public InstrumentSetChannelsEvent(NetEntity uid, MidiTrack?[] tracks)
+ {
+ Uid = uid;
+ Tracks = tracks;
+ }
+}
+
+///
+/// Represents a single midi track with the track name, instrument name and bank instrument name extracted.
+///
+[Serializable, NetSerializable]
+public sealed class MidiTrack
+{
+ ///
+ /// The first specified Track Name
+ ///
+ public string? TrackName;
+ ///
+ /// The first specified instrument name
+ ///
+ public string? InstrumentName;
+
+ ///
+ /// The first program change resolved to the name.
+ ///
+ public string? ProgramName;
+
+ public override string ToString()
+ {
+ return $"Track Name: {TrackName}; Instrument Name: {InstrumentName}; Program Name: {ProgramName}";
+ }
+
+ ///
+ /// Truncates the fields based on the limit inputted into this method.
+ ///
+ public void TruncateFields(int limit)
+ {
+ if (InstrumentName != null)
+ InstrumentName = Truncate(InstrumentName, limit);
+
+ if (TrackName != null)
+ TrackName = Truncate(TrackName, limit);
+
+ if (ProgramName != null)
+ ProgramName = Truncate(ProgramName, limit);
+ }
+
+ public void SanitizeFields()
+ {
+ if (InstrumentName != null)
+ InstrumentName = Sanitize(InstrumentName);
+
+ if (TrackName != null)
+ TrackName = Sanitize(TrackName);
+
+ if (ProgramName != null)
+ ProgramName = Sanitize(ProgramName);
+ }
+
+ private const string Postfix = "…";
+ // TODO: Make a general method to use in RT? idk if we have that.
+ private string Truncate(string input, int limit)
+ {
+ if (string.IsNullOrEmpty(input) || limit <= 0 || input.Length <= limit)
+ return input;
+
+ var truncatedLength = limit - Postfix.Length;
+
+ return input.Substring(0, truncatedLength) + Postfix;
+ }
+
+ private static string Sanitize(string input)
+ {
+ var sanitized = new StringBuilder(input.Length);
+
+ foreach (char c in input)
+ {
+ if (!char.IsControl(c) && c <= 127) // no control characters, only ASCII
+ sanitized.Append(c);
+ }
+
+ return sanitized.ToString();
+ }
+}
diff --git a/Resources/Locale/en-US/instruments/instruments-component.ftl b/Resources/Locale/en-US/instruments/instruments-component.ftl
index f0e0c1b3a9..76cfb28166 100644
--- a/Resources/Locale/en-US/instruments/instruments-component.ftl
+++ b/Resources/Locale/en-US/instruments/instruments-component.ftl
@@ -19,6 +19,139 @@ instruments-component-channels-menu = MIDI Channel Selection
instrument-component-channel-name = MIDI Channel {$number}
instruments-component-channels-all-button = All
instruments-component-channels-clear-button = Clear
+instruments-component-channels-track-names-toggle = Show Track Names
+instruments-component-channels-single = {$channel} {$name}
+instruments-component-channels-multi = {$channel} {$name} ({$other})
+
# SwappableInstrumentComponent
swappable-instrument-component-style-set = Style set to "{$style}"
+
+instruments-component-menu-midi-channel-acoustic-grand-piano = Acoustic Grand Piano
+instruments-component-menu-midi-channel-bright-acoustic-piano = Bright Acoustic Piano
+instruments-component-menu-midi-channel-electric-grand-piano = Electric Grand Piano
+instruments-component-menu-midi-channel-honky-tonk-piano = Honky-tonk Piano
+instruments-component-menu-midi-channel-rhodes-piano = Rhodes Piano
+instruments-component-menu-midi-channel-chorused-piano = Chorused Piano
+instruments-component-menu-midi-channel-harpsichord = Harpsichord
+instruments-component-menu-midi-channel-clavinet = Clavinet
+instruments-component-menu-midi-channel-celesta = Celesta
+instruments-component-menu-midi-channel-glockenspiel = Glockenspiel
+instruments-component-menu-midi-channel-music-box = Music Box
+instruments-component-menu-midi-channel-vibraphone = Vibraphone
+instruments-component-menu-midi-channel-marimba = Marimba
+instruments-component-menu-midi-channel-xylophone = Xylophone
+instruments-component-menu-midi-channel-tubular-bells = Tubular Bells
+instruments-component-menu-midi-channel-dulcimer = Dulcimer
+instruments-component-menu-midi-channel-hammond-organ = Hammond Organ
+instruments-component-menu-midi-channel-percussive-organ = Percussive Organ
+instruments-component-menu-midi-channel-rock-organ = Rock Organ
+instruments-component-menu-midi-channel-church-organ = Church Organ
+instruments-component-menu-midi-channel-reed-organ = Reed Organ
+instruments-component-menu-midi-channel-accordion = Accordion
+instruments-component-menu-midi-channel-harmonica = Harmonica
+instruments-component-menu-midi-channel-tango-accordion = Tango Accordion
+instruments-component-menu-midi-channel-acoustic-nylon-guitar = Acoustic Nylon Guitar
+instruments-component-menu-midi-channel-acoustic-steel-guitar = Acoustic Steel Guitar
+instruments-component-menu-midi-channel-electric-jazz-guitar = Electric Jazz Guitar
+instruments-component-menu-midi-channel-electric-clean-guitar = Electric Clean Guitar
+instruments-component-menu-midi-channel-electric-muted-guitar = Electric Muted Guitar
+instruments-component-menu-midi-channel-overdriven-guitar = Overdriven Guitar
+instruments-component-menu-midi-channel-distortion-guitar = Distortion Guitar
+instruments-component-menu-midi-channel-guitar-harmonics = Guitar Harmonics
+instruments-component-menu-midi-channel-acoustic-bass = Acoustic Bass
+instruments-component-menu-midi-channel-fingered-electric-bass = Fingered Electric Bass
+instruments-component-menu-midi-channel-plucked-electric-bass = Plucked Electric Bass
+instruments-component-menu-midi-channel-fretless-bass = Fretless Bass
+instruments-component-menu-midi-channel-slap-bass1 = Slap Bass 1
+instruments-component-menu-midi-channel-slap-bass2 = Slap Bass 2
+instruments-component-menu-midi-channel-synth-bass1 = Synth Bass 1
+instruments-component-menu-midi-channel-synth-bass2 = Synth Bass 2
+instruments-component-menu-midi-channel-violin = Violin
+instruments-component-menu-midi-channel-viola = Viola
+instruments-component-menu-midi-channel-cello = Cello
+instruments-component-menu-midi-channel-contrabass = Contrabass
+instruments-component-menu-midi-channel-tremolo-strings = Tremolo Strings
+instruments-component-menu-midi-channel-pizzicato-strings = Pizzicato Strings
+instruments-component-menu-midi-channel-orchestral-harp = Orchestral Harp
+instruments-component-menu-midi-channel-timpani = Timpani
+instruments-component-menu-midi-channel-string-ensemble1 = String Ensemble 1
+instruments-component-menu-midi-channel-string-ensemble2 = String Ensemble 2
+instruments-component-menu-midi-channel-synth-strings1 = Synth Strings 1
+instruments-component-menu-midi-channel-synth-strings2 = Synth Strings 2
+instruments-component-menu-midi-channel-choir-aah = Choir "Aah"
+instruments-component-menu-midi-channel-voice-ooh = Voice "Ooh"
+instruments-component-menu-midi-channel-synth-choir = Synth Choir
+instruments-component-menu-midi-channel-orchestra-hit = Orchestra Hit
+instruments-component-menu-midi-channel-trumpet = Trumpet
+instruments-component-menu-midi-channel-trombone = Trombone
+instruments-component-menu-midi-channel-tuba = Tuba
+instruments-component-menu-midi-channel-muted-trumpet = Muted Trumpet
+instruments-component-menu-midi-channel-french-horn = French Horn
+instruments-component-menu-midi-channel-brass-section = Brass Section
+instruments-component-menu-midi-channel-synth-brass1 = Synth Brass 1
+instruments-component-menu-midi-channel-synth-brass2 = Synth Brass 2
+instruments-component-menu-midi-channel-soprano-sax = Soprano Sax
+instruments-component-menu-midi-channel-alto-sax = Alto Sax
+instruments-component-menu-midi-channel-tenor-sax = Tenor Sax
+instruments-component-menu-midi-channel-baritone-sax = Baritone Sax
+instruments-component-menu-midi-channel-oboe = Oboe
+instruments-component-menu-midi-channel-english-horn = English Horn
+instruments-component-menu-midi-channel-bassoon = Bassoon
+instruments-component-menu-midi-channel-clarinet = Clarinet
+instruments-component-menu-midi-channel-piccolo = Piccolo
+instruments-component-menu-midi-channel-flute = Flute
+instruments-component-menu-midi-channel-recorder = Recorder
+instruments-component-menu-midi-channel-pan-flute = Pan Flute
+instruments-component-menu-midi-channel-bottle-blow = Bottle Blow
+instruments-component-menu-midi-channel-shakuhachi = Shakuhachi
+instruments-component-menu-midi-channel-whistle = Whistle
+instruments-component-menu-midi-channel-ocarina = Ocarina
+instruments-component-menu-midi-channel-square-wave-lead = Square Wave Lead
+instruments-component-menu-midi-channel-sawtooth-wave-lead = Sawtooth Wave Lead
+instruments-component-menu-midi-channel-calliope-lead = Calliope Lead
+instruments-component-menu-midi-channel-chiff-lead = Chiff Lead
+instruments-component-menu-midi-channel-charang-lead = Charang Lead
+instruments-component-menu-midi-channel-voice-lead = Voice Lead
+instruments-component-menu-midi-channel-fiths-lead = Fiths Lead
+instruments-component-menu-midi-channel-bass-lead = Bass Lead
+instruments-component-menu-midi-channel-new-age-pad = New Age Pad
+instruments-component-menu-midi-channel-warm-pad = Warm Pad
+instruments-component-menu-midi-channel-polysynth-pad = Polysynth Pad
+instruments-component-menu-midi-channel-choir-pad = Choir Pad
+instruments-component-menu-midi-channel-bowed-pad = Bowed Pad
+instruments-component-menu-midi-channel-metallic-pad = Metallic Pad
+instruments-component-menu-midi-channel-halo-pad = Halo Pad
+instruments-component-menu-midi-channel-sweep-pad = Sweep Pad
+instruments-component-menu-midi-channel-rain-effect = Rain Effect
+instruments-component-menu-midi-channel-soundtrack-effect = Soundtrack Effect
+instruments-component-menu-midi-channel-crystal-effect = Crystal Effect
+instruments-component-menu-midi-channel-atmosphere-effect = Atmosphere Effect
+instruments-component-menu-midi-channel-brightness-effect = Brightness Effect
+instruments-component-menu-midi-channel-goblins-effect = Goblins Effect
+instruments-component-menu-midi-channel-echoes-effect = Echoes Effect
+instruments-component-menu-midi-channel-sci-fi-effect = Sci-Fi Effect
+instruments-component-menu-midi-channel-sitar = Sitar
+instruments-component-menu-midi-channel-banjo = Banjo
+instruments-component-menu-midi-channel-shamisen = Shamisen
+instruments-component-menu-midi-channel-koto = Koto
+instruments-component-menu-midi-channel-kalimba = Kalimba
+instruments-component-menu-midi-channel-bagpipe = Bagpipe
+instruments-component-menu-midi-channel-fiddle = Fiddle
+instruments-component-menu-midi-channel-shanai = Shanai
+instruments-component-menu-midi-channel-tinkle-bell = Tinkle Bell
+instruments-component-menu-midi-channel-agogo = Agogo
+instruments-component-menu-midi-channel-steel-drums = Steel Drums
+instruments-component-menu-midi-channel-woodblock = Woodblock
+instruments-component-menu-midi-channel-taiko-drum = Taiko Drum
+instruments-component-menu-midi-channel-melodic-tom = Melodic Tom
+instruments-component-menu-midi-channel-synth-drum = Synth Drum
+instruments-component-menu-midi-channel-reverse-cymbal = Reverse Cymbal
+instruments-component-menu-midi-channel-guitar-fret-noise = Guitar Fret Noise
+instruments-component-menu-midi-channel-breath-noise = Breath Noise
+instruments-component-menu-midi-channel-seashore = Seashore
+instruments-component-menu-midi-channel-bird-tweet = Bird Tweet
+instruments-component-menu-midi-channel-telephone-ring = Telephone Ring
+instruments-component-menu-midi-channel-helicopter = Helicopter
+instruments-component-menu-midi-channel-applause = Applause
+instruments-component-menu-midi-channel-gunshot = Gunshot
diff --git a/Resources/Prototypes/Entities/Objects/Fun/Instruments/instruments_percussion.yml b/Resources/Prototypes/Entities/Objects/Fun/Instruments/instruments_percussion.yml
index 7ad65e52b2..82f9ded952 100644
--- a/Resources/Prototypes/Entities/Objects/Fun/Instruments/instruments_percussion.yml
+++ b/Resources/Prototypes/Entities/Objects/Fun/Instruments/instruments_percussion.yml
@@ -1,4 +1,16 @@
-- type: entity
+# SPDX-FileCopyrightText: 2023 DanSAussieITS
+# SPDX-FileCopyrightText: 2023 Nemanja
+# SPDX-FileCopyrightText: 2023 metalgearsloth
+# SPDX-FileCopyrightText: 2024 Dvir
+# SPDX-FileCopyrightText: 2024 Moomoobeef
+# SPDX-FileCopyrightText: 2024 Nairod
+# SPDX-FileCopyrightText: 2024 SlamBamActionman
+# SPDX-FileCopyrightText: 2024 lzk
+# SPDX-FileCopyrightText: 2025 bitcrushing
+#
+# SPDX-License-Identifier: MPL-2.0
+
+- type: entity
parent: BaseHandheldInstrument
id: GlockenspielInstrument
name: glockenspiel
@@ -138,7 +150,6 @@
- type: Instrument
allowPercussion: true
allowProgramChange: true
- respectMidiLimits: false
- type: Sprite
sprite: Objects/Fun/Instruments/h_synthesizer.rsi
state: supersynth
@@ -146,3 +157,11 @@
heldPrefix: super
size: Normal
sprite: Objects/Fun/Instruments/h_synthesizer.rsi
+
+- type: entity
+ parent: SuperSynthesizerInstrument
+ id: SuperSynthesizerNoLimitInstrument
+ suffix: NoLimits
+ components:
+ - type: Instrument
+ respectMidiLimits: false
diff --git a/Resources/Prototypes/_DV/Entities/Mobs/Species/harpy.yml b/Resources/Prototypes/_DV/Entities/Mobs/Species/harpy.yml
index 0882e85e1b..3b276caf2b 100644
--- a/Resources/Prototypes/_DV/Entities/Mobs/Species/harpy.yml
+++ b/Resources/Prototypes/_DV/Entities/Mobs/Species/harpy.yml
@@ -5,7 +5,9 @@
# SPDX-FileCopyrightText: 2025 Ark
# SPDX-FileCopyrightText: 2025 Dvir
# SPDX-FileCopyrightText: 2025 Redrover1760
+# SPDX-FileCopyrightText: 2025 ScyronX
# SPDX-FileCopyrightText: 2025 Whatstone
+# SPDX-FileCopyrightText: 2025 bitcrushing
# SPDX-FileCopyrightText: 2025 core-mene
#
# SPDX-License-Identifier: AGPL-3.0-or-later
@@ -34,7 +36,6 @@
"Piano": {1: 0}
"Church Organ": {19: 0}
"Harp": {46: 0}
- onlySetBySelf: true
- type: UserInterface
interfaces:
enum.InstrumentUiKey.Key: