diff --git a/Daybreak/Daybreak.csproj b/Daybreak/Daybreak.csproj index 0cf9331b..cd8d425f 100644 --- a/Daybreak/Daybreak.csproj +++ b/Daybreak/Daybreak.csproj @@ -13,7 +13,7 @@ preview Daybreak.ico true - 0.9.8.9 + 0.9.8.10 true cfb2a489-db80-448d-a969-80270f314c46 diff --git a/Daybreak/Models/Guildwars/GameData.cs b/Daybreak/Models/Guildwars/GameData.cs index d92c4225..d751a2dd 100644 --- a/Daybreak/Models/Guildwars/GameData.cs +++ b/Daybreak/Models/Guildwars/GameData.cs @@ -5,6 +5,7 @@ namespace Daybreak.Models; public sealed class GameData { + public bool Valid { get; init; } public MainPlayerInformation? MainPlayer { get; init; } public List? Party { get; init; } public UserInformation? User { get; init; } diff --git a/Daybreak/Models/Guildwars/PlayerInformation.cs b/Daybreak/Models/Guildwars/PlayerInformation.cs index e6599afb..efead409 100644 --- a/Daybreak/Models/Guildwars/PlayerInformation.cs +++ b/Daybreak/Models/Guildwars/PlayerInformation.cs @@ -7,6 +7,7 @@ public class PlayerInformation public Profession? PrimaryProfession { get; init; } public Profession? SecondaryProfession { get; init; } public List? UnlockedProfession { get; init; } + public Build? CurrentBuild { get; init; } public float CurrentHealth { get; init; } public float MaxHealth { get; init; } public float CurrentEnergy { get; init; } diff --git a/Daybreak/Models/Guildwars/SkillMetadata.cs b/Daybreak/Models/Guildwars/SkillMetadata.cs new file mode 100644 index 00000000..a896efa3 --- /dev/null +++ b/Daybreak/Models/Guildwars/SkillMetadata.cs @@ -0,0 +1,11 @@ +namespace Daybreak.Models.Guildwars; + +public sealed class SkillMetadata +{ + public Skill? Skill { get; init; } + public uint Adrenaline1 { get; init; } + public uint Adrenaline2 { get; init; } + public uint Recharge { get; init; } + public uint Id { get; init; } + public uint Event { get; init; } +} diff --git a/Daybreak/Models/Guildwars/WorldPlayerInformation.cs b/Daybreak/Models/Guildwars/WorldPlayerInformation.cs index 4cf6c1e0..7834429c 100644 --- a/Daybreak/Models/Guildwars/WorldPlayerInformation.cs +++ b/Daybreak/Models/Guildwars/WorldPlayerInformation.cs @@ -1,4 +1,6 @@ -namespace Daybreak.Models.Guildwars; +using System.Collections.Generic; + +namespace Daybreak.Models.Guildwars; public class WorldPlayerInformation : PlayerInformation { diff --git a/Daybreak/Models/Interop/AttributeContext.cs b/Daybreak/Models/Interop/AttributeContext.cs new file mode 100644 index 00000000..1ce10650 --- /dev/null +++ b/Daybreak/Models/Interop/AttributeContext.cs @@ -0,0 +1,10 @@ +namespace Daybreak.Models.Interop; + +public readonly struct AttributeContext +{ + public readonly uint Id; + public readonly uint BaseLevel; + public readonly uint ActualLevel; + public readonly uint DecrementPoints; + public readonly uint IncrementPoints; +} diff --git a/Daybreak/Models/Interop/GameContext.cs b/Daybreak/Models/Interop/GameContext.cs index d9a8599b..c3103bc9 100644 --- a/Daybreak/Models/Interop/GameContext.cs +++ b/Daybreak/Models/Interop/GameContext.cs @@ -12,6 +12,9 @@ public readonly struct GameContext ///Array of type . public readonly GuildwarsArray MapEntities; + [FieldOffset(0x0030)] + public readonly GuildwarsArray PartyAttributes; + [FieldOffset(0x04AC)] public readonly uint QuestId; @@ -21,10 +24,13 @@ public readonly struct GameContext [FieldOffset(0x0608)] public readonly uint HardModeUnlocked; - [FieldOffset(0x640)] + [FieldOffset(0x0640)] ///Array of type public readonly GuildwarsArray Professions; + [FieldOffset(0x0674)] + public readonly GuildwarsArray Skillbars; + [FieldOffset(0x06C4)] public readonly uint Experience; diff --git a/Daybreak/Models/Interop/PartyAttributesContext.cs b/Daybreak/Models/Interop/PartyAttributesContext.cs new file mode 100644 index 00000000..a0d15be1 --- /dev/null +++ b/Daybreak/Models/Interop/PartyAttributesContext.cs @@ -0,0 +1,11 @@ +using System.Runtime.InteropServices; + +namespace Daybreak.Models.Interop; + +public readonly struct PartyAttributesContext +{ + public readonly uint AgentId; + + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 0x36)] + public readonly AttributeContext[] Attributes; +} diff --git a/Daybreak/Models/Interop/SkillContext.cs b/Daybreak/Models/Interop/SkillContext.cs new file mode 100644 index 00000000..9afd55d4 --- /dev/null +++ b/Daybreak/Models/Interop/SkillContext.cs @@ -0,0 +1,17 @@ +using System.Runtime.InteropServices; + +namespace Daybreak.Models.Interop; + +[StructLayout(LayoutKind.Sequential)] +public readonly struct SkillContext +{ + public readonly uint Adrenaline1; + + public readonly uint Adrenaline2; + + public readonly uint Recharge; + + public readonly uint Id; + + public readonly uint Event; +} diff --git a/Daybreak/Models/Interop/SkillbarContext.cs b/Daybreak/Models/Interop/SkillbarContext.cs new file mode 100644 index 00000000..7de49246 --- /dev/null +++ b/Daybreak/Models/Interop/SkillbarContext.cs @@ -0,0 +1,23 @@ +using System.Runtime.InteropServices; + +namespace Daybreak.Models.Interop; + +[StructLayout(LayoutKind.Explicit)] +public readonly struct SkillbarContext +{ + [FieldOffset(0x0000)] + public readonly uint AgentId; + + [FieldOffset(0x0004)] + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 0x08)] + public readonly SkillContext[] Skills; + + [FieldOffset(0x00A4)] + public readonly uint Disabled; + + [FieldOffset(0x00B0)] + public readonly uint Casting; + + [FieldOffset(0x00B8)] + public readonly uint H00B8; +} diff --git a/Daybreak/Services/Scanner/GuildwarsMemoryReader.cs b/Daybreak/Services/Scanner/GuildwarsMemoryReader.cs index d2810520..0480bda0 100644 --- a/Daybreak/Services/Scanner/GuildwarsMemoryReader.cs +++ b/Daybreak/Services/Scanner/GuildwarsMemoryReader.cs @@ -1,17 +1,19 @@ using Daybreak.Models; +using Daybreak.Models.Builds; using Daybreak.Models.Guildwars; using Daybreak.Models.Interop; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using System.Core.Extensions; using System.Diagnostics; using System.Extensions; using System.Linq; using System.Logging; -using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Windows.Documents; namespace Daybreak.Services.Scanner; @@ -26,6 +28,8 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable private IntPtr entityArrayPointer; private volatile CancellationTokenSource? cancellationTokenSource; + public bool Faulty { get; private set; } + public bool Running => this.cancellationTokenSource is not null && this.cancellationTokenSource.IsCancellationRequested is false; public GameData? GameData { get; private set; } @@ -121,9 +125,12 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable try { this.ReadGameMemory(cancellationToken); + this.Faulty = false; } catch(Exception e) { + this.GameData = null; + this.Faulty = true; this.logger.LogError(e, "Exception encountered when reading game memory"); } } @@ -157,13 +164,16 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable var professions = this.memoryScanner.ReadArray(gameContext.Professions); var players = this.memoryScanner.ReadArray(gameContext.Players); var quests = this.memoryScanner.ReadArray(gameContext.QuestLog); + var skills = this.memoryScanner.ReadArray(gameContext.Skillbars); + var partyAttributes = this.memoryScanner.ReadArray(gameContext.PartyAttributes); var playerEntityId = this.memoryScanner.ReadPtrChain(this.GetPlayerIdPointer(), 0x0, 0x0); + // The following lines would retrieve all entities, including item entities. //var entityArray = this.memoryScanner.ReadPtrChain(this.GetEntityArrayPointer(), 0x0, 0x0); //var entities = this.memoryScanner.ReadArray(entityArray); - this.GameData = this.AggregateGameData(gameContext, instanceContext, mapEntities, players, professions, quests, userContext, playerEntityId); + this.GameData = this.AggregateGameData(gameContext, instanceContext, mapEntities, players, professions, quests, userContext, skills, partyAttributes, playerEntityId); } private IntPtr GetPlayerIdPointer() @@ -185,7 +195,7 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable return this.entityArrayPointer; } - + private GameData AggregateGameData( GameContext gameContext, InstanceContext instanceContext, @@ -194,6 +204,8 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable ProfessionsContext[] professions, QuestContext[] quests, UserContext userContext, + SkillbarContext[] skills, + PartyAttributesContext[] partyAttributes, int mainPlayerEntityId) { var email = ParseAndCleanWCharArray(userContext.PlayerEmailBytes); @@ -201,14 +213,14 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable _ = Map.TryParse((int)userContext.MapId, out var currentMap); var partyMembers = professions .Where(p => p.AgentId != mainPlayerEntityId) - .Select(p => GetPlayerInformation((int)p.AgentId, instanceContext, entities, professions)) + .Select(p => GetPlayerInformation((int)p.AgentId, instanceContext, entities, professions, skills, partyAttributes)) .ToList(); - var mainPlayer = GetMainPlayerInformation(mainPlayerEntityId, name, gameContext, instanceContext, entities, professions, quests); + var mainPlayer = GetMainPlayerInformation(mainPlayerEntityId, name, gameContext, instanceContext, entities, professions, quests, skills, partyAttributes); var worldPlayers = players .Where(p => p.AgentId != mainPlayerEntityId) - .Select(p => GetWorldPlayerInformation(p, this.memoryScanner.ReadWString(p.NamePointer, 0x40), instanceContext, entities, professions)) + .Select(p => GetWorldPlayerInformation(p, this.memoryScanner.ReadWString(p.NamePointer, 0x40), instanceContext, entities, professions, skills, partyAttributes)) .ToList(); @@ -245,7 +257,8 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable MainPlayer = mainPlayer, Session = sessionInformation, User = userInformation, - WorldPlayers = worldPlayers + WorldPlayers = worldPlayers, + Valid = entities.Length > 0 || players.Length > 0 || professions.Length > 0 || quests.Length > 0 || skills.Length > 0 || partyAttributes.Length > 0 }; } @@ -256,9 +269,11 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable InstanceContext instanceContext, MapEntityContext[] entities, ProfessionsContext[] professions, - QuestContext[] quests) + QuestContext[] quests, + SkillbarContext[] skillbars, + PartyAttributesContext[] partyAttributes) { - var playerInformation = GetPlayerInformation(mainPlayerId, instanceContext, entities, professions); + var playerInformation = GetPlayerInformation(mainPlayerId, instanceContext, entities, professions, skillbars, partyAttributes); _ = Quest.TryParse((int)gameContext.QuestId, out var quest); var questLog = quests .Select(q => @@ -288,7 +303,8 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable Name = name, Experience = gameContext.Experience, Level = gameContext.Level, - Morale = gameContext.Morale + Morale = gameContext.Morale, + CurrentBuild = playerInformation.CurrentBuild }; } @@ -297,9 +313,11 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable string name, InstanceContext instanceContext, MapEntityContext[] entities, - ProfessionsContext[] professions) + ProfessionsContext[] professions, + SkillbarContext[] skillbars, + PartyAttributesContext[] partyAttributes) { - var playerInformation = GetPlayerInformation(playerContext.AgentId, instanceContext, entities, professions); + var playerInformation = GetPlayerInformation(playerContext.AgentId, instanceContext, entities, professions, skillbars, partyAttributes); return new WorldPlayerInformation { PrimaryProfession = playerInformation.PrimaryProfession, @@ -312,6 +330,7 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable EnergyRegen = playerInformation.EnergyRegen, HealthRegen = playerInformation.HealthRegen, Name = name, + CurrentBuild = playerInformation.CurrentBuild }; } @@ -319,7 +338,9 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable int playerId, InstanceContext instanceContext, MapEntityContext[] entities, - ProfessionsContext[] professions) + ProfessionsContext[] professions, + SkillbarContext[] skillbars, + PartyAttributesContext[] partyAttributes) { var entityContext = entities.Skip(playerId).FirstOrDefault(); var professionContext = professions.Where(p => p.AgentId == playerId).FirstOrDefault(); @@ -331,6 +352,44 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable .Where(p => p is not null && p != Profession.None) .OrderBy(p => p.Id) .ToList(); + var maybeSkillbarContext = skillbars.Select(s => (SkillbarContext?) s).FirstOrDefault(s => s?.AgentId == playerId); + var maybePartyAttributesContext = partyAttributes.Select(p => (PartyAttributesContext?) p).FirstOrDefault(p => p?.AgentId == playerId); + Build? build = null; + if (maybeSkillbarContext is SkillbarContext skillbarContext && + maybePartyAttributesContext is PartyAttributesContext attributesContext && + primaryProfession is not null && + secondaryProfession is not null) + { + var attributes = new List { primaryProfession.PrimaryAttribute! } + .Concat(primaryProfession.Attributes) + .Concat(secondaryProfession.Attributes) + .Select(a => new AttributeEntry { Attribute = a }) + .ToList(); + foreach(var attribute in attributesContext.Attributes) + { + if (attribute.Id < 0 || attribute.Id > 44) + { + continue; + } + + var maybeAttributeEntry = attributes.FirstOrDefault(a => a.Attribute!.Id == attribute.Id); + if (maybeAttributeEntry is not AttributeEntry attributeEntry) + { + continue; + } + + attributeEntry.Points = (int)attribute.BaseLevel; + } + + build = new Build + { + Primary = primaryProfession, + Secondary = secondaryProfession, + Attributes = attributes, + Skills = skillbarContext.Skills.Select(s => Skill.Parse((int)s.Id)).ToList() + }; + } + (var currentHp, var currentEnergy) = ApplyEnergyAndHealthRegen(instanceContext, entityContext); return new PlayerInformation { @@ -342,7 +401,8 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable MaxHealth = entityContext.MaxHealth, MaxEnergy = entityContext.MaxEnergy, HealthRegen = entityContext.HealthRegen, - EnergyRegen = entityContext.EnergyRegen + EnergyRegen = entityContext.EnergyRegen, + CurrentBuild = build }; } @@ -377,5 +437,6 @@ public sealed class GuildwarsMemoryReader : IGuildwarsMemoryReader, IDisposable this.cancellationTokenSource?.Cancel(); this.cancellationTokenSource?.Dispose(); this.cancellationTokenSource = null; + this.Faulty = false; } } diff --git a/Daybreak/Services/Scanner/IGuildwarsMemoryReader.cs b/Daybreak/Services/Scanner/IGuildwarsMemoryReader.cs index aef28838..08f962da 100644 --- a/Daybreak/Services/Scanner/IGuildwarsMemoryReader.cs +++ b/Daybreak/Services/Scanner/IGuildwarsMemoryReader.cs @@ -5,6 +5,7 @@ namespace Daybreak.Services.Scanner; public interface IGuildwarsMemoryReader { + bool Faulty { get; } bool Running { get; } GameData? GameData { get; } Process? TargetProcess { get; } diff --git a/Daybreak/Views/FocusView.xaml b/Daybreak/Views/FocusView.xaml index 3a378edb..581a998e 100644 --- a/Daybreak/Views/FocusView.xaml +++ b/Daybreak/Views/FocusView.xaml @@ -5,6 +5,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="clr-namespace:Daybreak.Views" xmlns:controls="clr-namespace:Daybreak.Controls" + xmlns:converters="clr-namespace:Daybreak.Converters" xmlns:templates="clr-namespace:Daybreak.Controls.Templates" mc:Ignorable="d" Foreground="White" @@ -14,6 +15,7 @@ d:DesignHeight="450" d:DesignWidth="800"> + @@ -32,7 +34,8 @@ Grid.Column="1" Grid.ColumnSpan="2" Grid.Row="2" - Grid.RowSpan="2"> + Grid.RowSpan="2" + Visibility="{Binding ElementName=_this, Path=GameData.Valid, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"> @@ -238,19 +241,90 @@ + Grid.Column="1" + Visibility="{Binding ElementName=_this, Path=GameData.Valid, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"> + + + + + + + + + + + + + + + + + + + + diff --git a/Daybreak/Views/FocusView.xaml.cs b/Daybreak/Views/FocusView.xaml.cs index c8f68c36..b49070ab 100644 --- a/Daybreak/Views/FocusView.xaml.cs +++ b/Daybreak/Views/FocusView.xaml.cs @@ -1,7 +1,9 @@ using Daybreak.Configuration; using Daybreak.Models; +using Daybreak.Models.Builds; using Daybreak.Models.Guildwars; using Daybreak.Services.ApplicationLauncher; +using Daybreak.Services.BuildTemplates; using Daybreak.Services.Experience; using Daybreak.Services.Navigation; using Daybreak.Services.Scanner; @@ -15,6 +17,7 @@ using System.Threading; using System.Windows; using System.Windows.Controls; using System.Windows.Extensions; +using System.Windows.Media.Animation; namespace Daybreak.Views; @@ -23,8 +26,12 @@ namespace Daybreak.Views; /// public partial class FocusView : UserControl { + private const string InfoNamePlaceholder = "[NAME]"; + private const string WikiBaseAddress = $"https://wiki.guildwars.com/wiki/{InfoNamePlaceholder}"; + private const string BuildsAddress = $"https://gwpvx.fandom.com/wiki/Category:Builds_by_category"; private const double BarsTotalSize = 116; // Size of the bars on one side of the screen. + private readonly IBuildTemplateManager buildTemplateManager; private readonly IApplicationLauncher applicationLauncher; private readonly IGuildwarsMemoryReader guildwarsMemoryReader; private readonly IExperienceCalculator experienceCalculator; @@ -74,6 +81,7 @@ public partial class FocusView : UserControl private bool browserMaximized = false; public FocusView( + IBuildTemplateManager buildTemplateManager, IApplicationLauncher applicationLauncher, IGuildwarsMemoryReader guildwarsMemoryReader, IExperienceCalculator experienceCalculator, @@ -81,6 +89,7 @@ public partial class FocusView : UserControl ILiveUpdateableOptions liveUpdateableOptions, ILogger logger) { + this.buildTemplateManager = buildTemplateManager.ThrowIfNull(); this.applicationLauncher = applicationLauncher.ThrowIfNull(); this.guildwarsMemoryReader = guildwarsMemoryReader.ThrowIfNull(); this.experienceCalculator = experienceCalculator.ThrowIfNull(); @@ -126,7 +135,8 @@ public partial class FocusView : UserControl this.viewManager.ShowView(); } - if (this.guildwarsMemoryReader.Running is false) + if (this.guildwarsMemoryReader.Running is false || + this.guildwarsMemoryReader.Faulty) { this.guildwarsMemoryReader.Initialize(this.applicationLauncher.RunningGuildwarsProcess!); } @@ -134,6 +144,7 @@ public partial class FocusView : UserControl this.Dispatcher.Invoke(() => { this.GameData = this.guildwarsMemoryReader.GameData; + this.Browser.Visibility = this.GameData?.Valid is true ? Visibility.Visible : Visibility.Collapsed; if (this.GameData?.MainPlayer is null || this.GameData?.User is null || this.GameData?.Session is null) @@ -491,6 +502,37 @@ public partial class FocusView : UserControl } } + private void MetaBuilds_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + this.BrowserAddress = BuildsAddress; + } + + private void PrimaryProfession_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (this.GameData.MainPlayer?.PrimaryProfession?.Name is string professionName) + { + this.BrowserAddress = WikiBaseAddress.Replace(InfoNamePlaceholder, professionName); + } + } + + private void SecondaryProfession_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (this.GameData.MainPlayer?.SecondaryProfession?.Name is string professionName) + { + this.BrowserAddress = WikiBaseAddress.Replace(InfoNamePlaceholder, professionName); + } + } + + private void EditBuild_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + if (this.GameData.MainPlayer?.CurrentBuild is Build build) + { + var buildEntry = this.buildTemplateManager.CreateBuild(); + buildEntry.Build = build; + this.viewManager.ShowView(buildEntry); + } + } + private void Browser_MaximizeClicked(object sender, EventArgs e) { this.browserMaximized = !this.browserMaximized; diff --git a/Scripts/CompareVersions.ps1 b/Scripts/CompareVersions.ps1 index f03b5115..dddbfe78 100644 --- a/Scripts/CompareVersions.ps1 +++ b/Scripts/CompareVersions.ps1 @@ -13,10 +13,36 @@ if ($lastVersion.StartsWith("v")){ $lastVersion = $lastVersion.Substring(1) } -$isNewer = $currentVersion.CompareTo($lastVersion) -eq 1 -if ($isNewer -eq $false){ - throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion +$currentVersionTokens = $currentVersion.Split('.') +$lastVersionTokens = $lastVersion.Split('.') +if ($currentVersionTokens.Length -eq $lastVersionTokens.Length) +{ + for($i = 0 ; $i -lt $currentVersionTokens.Length; $i++) + { + $currentVersionToken = [int]$currentVersionTokens[$i] + $lastVersionToken = [int]$lastVersionTokens[$i] + if ($currentVersionToken -lt $lastVersionToken){ + throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion + } + } + + if ($currentVersionToken -le $lastVersionToken){ + throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion + } + + Write-Host "Version has been incremented" } -else{ +elseif ($currentVersionTokens.Length -gt $lastVersionTokens.Length){ + if ($currentVersionTokens[$lastVersionTokens.Length - 1] -lt $lastVersionTokens[$lastVersionTokens.Length - 1]){ + throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion + } + + Write-Host "Version has been incremented" +} +elseif ($currentVersionTokens.Length -lt $lastVersionTokens.Length){ + if ($currentVersionTokens[$currentVersionTokens.Length - 1] -le $lastVersionTokens[$currentVersionTokens.Length - 1]){ + throw "Version is not incremented. Current version " + $currentVersion + ". Last version " + $lastVersion + } + Write-Host "Version has been incremented" } \ No newline at end of file