diff --git a/.github/workflows/check-trailing-whitespace.yml b/.github/workflows/check-trailing-whitespace.yml deleted file mode 100644 index 3f94738d26..0000000000 --- a/.github/workflows/check-trailing-whitespace.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Trailing Whitespace Check - -on: - pull_request: - types: [ opened, reopened, synchronize, ready_for_review ] - -jobs: - build: - name: Trailing Whitespace Check - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4.2.2 - - name: Get changed text files - id: changed-files - uses: tj-actions/changed-files@v46.0.5 - with: - files: | - **.cs - **.yml - **.swsl - **.json - **.py - - name: Check for trailing whitespace and EOF newline - env: - ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} - run: | - has_trailing_whitespace=0 - has_missing_eof_newline=0 - - for file in ${ALL_CHANGED_FILES}; do - echo "Checking $file" - - # Check for trailing whitespace - if grep -qP '[ \t]+$' "$file"; then - echo "::error file=$file::Trailing whitespace found" - has_trailing_whitespace=1 - fi - - # Check for missing EOF newline - if [ -f "$file" ] && [ -s "$file" ]; then - last_char=$(tail -c 1 "$file") - if [ "$last_char" != "" ] && [ "$last_char" != $'\n' ]; then - echo "::error file=$file::Missing newline at end of file" - has_missing_eof_newline=1 - fi - fi - done - - if [ "$has_trailing_whitespace" -eq 1 ] || [ "$has_missing_eof_newline" -eq 1 ]; then - echo "Issues found: trailing whitespace or missing EOF newline." - echo "We recommend using an IDE to prevent this from happening." - exit 1 - fi diff --git a/Content.Client/Cargo/UI/CargoConsoleMenu.xaml.cs b/Content.Client/Cargo/UI/CargoConsoleMenu.xaml.cs index dfc61c0527..03246cfdfe 100644 --- a/Content.Client/Cargo/UI/CargoConsoleMenu.xaml.cs +++ b/Content.Client/Cargo/UI/CargoConsoleMenu.xaml.cs @@ -203,6 +203,9 @@ namespace Content.Client.Cargo.UI /// public void PopulateOrders(IEnumerable orders) { + if (!_orderConsoleQuery.TryComp(_owner, out var orderConsole)) + return; + Requests.DisposeAllChildren(); foreach (var order in orders) @@ -237,6 +240,7 @@ namespace Content.Client.Cargo.UI row.Cancel.OnPressed += (args) => { OnOrderCanceled?.Invoke(args); }; // TODO: Disable based on access. + row.SetApproveVisible(orderConsole.Mode != CargoOrderConsoleMode.SendToPrimary); row.Approve.OnPressed += (args) => { OnOrderApproved?.Invoke(args); }; Requests.AddChild(row); } @@ -290,8 +294,8 @@ namespace Content.Client.Cargo.UI TransferSpinBox.Value > bankAccount.Accounts[orderConsole.Account] * orderConsole.TransferLimit || _timing.CurTime < orderConsole.NextAccountActionTime; - OrdersSpacer.Visible = !orderConsole.SlipPrinter; - Orders.Visible = !orderConsole.SlipPrinter; + OrdersSpacer.Visible = orderConsole.Mode != CargoOrderConsoleMode.PrintSlip; + Orders.Visible = orderConsole.Mode != CargoOrderConsoleMode.PrintSlip; } } } diff --git a/Content.Client/Cargo/UI/CargoOrderRow.xaml.cs b/Content.Client/Cargo/UI/CargoOrderRow.xaml.cs index 77abcf4069..d2a23b5a2e 100644 --- a/Content.Client/Cargo/UI/CargoOrderRow.xaml.cs +++ b/Content.Client/Cargo/UI/CargoOrderRow.xaml.cs @@ -14,5 +14,15 @@ namespace Content.Client.Cargo.UI { RobustXamlLoader.Load(this); } + + public void SetApproveVisible(bool visible) + { + Approve.Visible = visible; + + if (visible) + Cancel.AddStyleClass("OpenLeft"); + else + Cancel.RemoveStyleClass("OpenLeft"); + } } } diff --git a/Content.Client/Chat/UI/SpeechBubble.cs b/Content.Client/Chat/UI/SpeechBubble.cs index 442368a3e6..0bfe6dc7c8 100644 --- a/Content.Client/Chat/UI/SpeechBubble.cs +++ b/Content.Client/Chat/UI/SpeechBubble.cs @@ -14,6 +14,7 @@ namespace Content.Client.Chat.UI { public abstract class SpeechBubble : Control { + [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] protected readonly IConfigurationManager ConfigManager = default!; @@ -30,12 +31,12 @@ namespace Content.Client.Chat.UI /// /// The total time a speech bubble stays on screen. /// - private const float TotalTime = 4; + private static readonly TimeSpan TotalTime = TimeSpan.FromSeconds(4); /// /// The amount of time at the end of the bubble's life at which it starts fading. /// - private const float FadeTime = 0.25f; + private static readonly TimeSpan FadeTime = TimeSpan.FromSeconds(0.25f); /// /// The distance in world space to offset the speech bubble from the center of the entity. @@ -50,7 +51,10 @@ namespace Content.Client.Chat.UI private readonly EntityUid _senderEntity; - private float _timeLeft = TotalTime; + /// + /// The time at which this bubble will die. + /// + private TimeSpan _deathTime; public float VerticalOffset { get; set; } private float _verticalOffsetAchieved; @@ -99,6 +103,7 @@ namespace Content.Client.Chat.UI bubble.Measure(Vector2Helpers.Infinity); ContentSize = bubble.DesiredSize; _verticalOffsetAchieved = -ContentSize.Y; + _deathTime = _timing.RealTime + TotalTime; } protected abstract Control BuildBubble(ChatMessage message, string speechStyleClass, Color? fontColor = null); @@ -107,8 +112,8 @@ namespace Content.Client.Chat.UI { base.FrameUpdate(args); - _timeLeft -= args.DeltaSeconds; - if (_entityManager.Deleted(_senderEntity) || _timeLeft <= 0) + var timeLeft = (float)(_deathTime - _timing.RealTime).TotalSeconds; + if (_entityManager.Deleted(_senderEntity) || timeLeft <= 0) { // Timer spawn to prevent concurrent modification exception. Timer.Spawn(0, Die); @@ -131,10 +136,10 @@ namespace Content.Client.Chat.UI return; } - if (_timeLeft <= FadeTime) + if (timeLeft <= FadeTime.TotalSeconds) { // Update alpha if we're fading. - Modulate = Color.White.WithAlpha(_timeLeft / FadeTime); + Modulate = Color.White.WithAlpha(timeLeft / (float)FadeTime.TotalSeconds); } else { @@ -144,7 +149,7 @@ namespace Content.Client.Chat.UI var baseOffset = 0f; - if (_entityManager.TryGetComponent(_senderEntity, out var speech)) + if (_entityManager.TryGetComponent(_senderEntity, out var speech)) baseOffset = speech.SpeechBubbleOffset; var offset = (-_eyeManager.CurrentEye.Rotation).ToWorldVec() * -(EntityVerticalOffset + baseOffset); @@ -175,9 +180,9 @@ namespace Content.Client.Chat.UI /// public void FadeNow() { - if (_timeLeft > FadeTime) + if (_deathTime > _timing.RealTime) { - _timeLeft = FadeTime; + _deathTime = _timing.RealTime + FadeTime; } } diff --git a/Content.Client/Construction/UI/ConstructionMenuPresenter.cs b/Content.Client/Construction/UI/ConstructionMenuPresenter.cs index b1cebab33a..119e92fc6f 100644 --- a/Content.Client/Construction/UI/ConstructionMenuPresenter.cs +++ b/Content.Client/Construction/UI/ConstructionMenuPresenter.cs @@ -38,7 +38,7 @@ namespace Content.Client.Construction.UI private ConstructionSystem? _constructionSystem; private ConstructionPrototype? _selected; private List _favoritedRecipes = []; - private Dictionary _recipeButtons = new(); + private readonly Dictionary _recipeButtons = new(); private string _selectedCategory = string.Empty; private const string FavoriteCatName = "construction-category-favorites"; @@ -217,8 +217,8 @@ namespace Content.Client.Construction.UI var itemButton = new ContainerButton() { VerticalAlignment = Control.VAlignment.Center, - Name = recipe.TargetPrototype.Name, - ToolTip = recipe.TargetPrototype.Name, + Name = recipe.Prototype.Name, + ToolTip = recipe.Prototype.Name, ToggleMode = true, Children = { protoView }, }; @@ -235,7 +235,7 @@ namespace Content.Client.Construction.UI if (buttonToggledEventArgs.Pressed && _selected != null && - _recipeButtons.TryGetValue(_selected.Name!, out var oldButton)) + _recipeButtons.TryGetValue(_selected.ID, out var oldButton)) { oldButton.Pressed = false; SelectGridButton(oldButton, false); @@ -245,7 +245,7 @@ namespace Content.Client.Construction.UI }; recipesGrid.AddChild(itemButtonPanelContainer); - _recipeButtons[recipe.Prototype.Name!] = itemButton; + _recipeButtons[recipe.Prototype.ID] = itemButton; var isCurrentButtonSelected = _selected == recipe.Prototype; itemButton.Pressed = isCurrentButtonSelected; SelectGridButton(itemButton, isCurrentButtonSelected); @@ -307,7 +307,7 @@ namespace Content.Client.Construction.UI if (button.Parent is not PanelContainer buttonPanel) return; - button.Modulate = select ? Color.Green : Color.Transparent; + button.Children.Single().Modulate = select ? Color.Green : Color.White; var buttonColor = select ? StyleNano.ButtonColorDefault : Color.Transparent; buttonPanel.PanelOverride = new StyleBoxFlat { BackgroundColor = buttonColor }; } diff --git a/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs b/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs new file mode 100644 index 0000000000..16aed930f6 --- /dev/null +++ b/Content.Client/Instruments/InstrumentSystem.MidiParsing.cs @@ -0,0 +1,54 @@ +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 abc3fa8210..d861f4163b 100644 --- a/Content.Client/Instruments/InstrumentSystem.cs +++ b/Content.Client/Instruments/InstrumentSystem.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Linq; using Content.Shared.CCVar; using Content.Shared.Instruments; @@ -12,7 +13,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 +24,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 +41,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) @@ -252,7 +275,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; @@ -263,6 +292,8 @@ public sealed class InstrumentSystem : SharedInstrumentSystem return false; SetMaster(uid, null); + TrySetChannels(uid, data); + instrument.MidiEventBuffer.Clear(); 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..93946496eb --- /dev/null +++ b/Content.Client/Instruments/MidiParser/MidiInstrument.cs @@ -0,0 +1,147 @@ +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..937384e439 --- /dev/null +++ b/Content.Client/Instruments/MidiParser/MidiParser.cs @@ -0,0 +1,184 @@ +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 + + 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..1886417a56 --- /dev/null +++ b/Content.Client/Instruments/MidiParser/MidiStreamWrapper.cs @@ -0,0 +1,103 @@ +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 @@