From 316947383558863194eb64c514a02c2e1841fafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Korczy=C5=84ski?= Date: Tue, 28 Sep 2021 13:00:32 +0100 Subject: [PATCH] [Packet viewer] Added creature text as a database diff processor --- .../Controls/BetterKeyBinding.cs | 56 +++++++ .../CreatureTextDumperProvider.cs | 26 +++- .../Processors/CreatureTextDumper.cs | 145 +++++++++++++++--- .../Utils/ChatEmoteSoundProcessor.cs | 37 ++++- WoWDatabaseEditor/Icons/chat_diff.png | Bin 0 -> 865 bytes WoWDatabaseEditor/Icons/chat_diff@2x.png | Bin 0 -> 1279 bytes WoWDatabaseEditor/Icons/chat_diff_big.png | Bin 0 -> 1279 bytes WoWDatabaseEditor/Icons/chat_diff_big@2x.png | Bin 0 -> 2025 bytes 8 files changed, 239 insertions(+), 25 deletions(-) create mode 100644 WDE.Common.Avalonia/Controls/BetterKeyBinding.cs create mode 100644 WoWDatabaseEditor/Icons/chat_diff.png create mode 100644 WoWDatabaseEditor/Icons/chat_diff@2x.png create mode 100644 WoWDatabaseEditor/Icons/chat_diff_big.png create mode 100644 WoWDatabaseEditor/Icons/chat_diff_big@2x.png diff --git a/WDE.Common.Avalonia/Controls/BetterKeyBinding.cs b/WDE.Common.Avalonia/Controls/BetterKeyBinding.cs new file mode 100644 index 000000000..02a80a396 --- /dev/null +++ b/WDE.Common.Avalonia/Controls/BetterKeyBinding.cs @@ -0,0 +1,56 @@ +using System; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; + +namespace WDE.Common.Avalonia.Controls +{ + /*** + * This is KeyBinding that forwards the gesture to the focused TextBox first + */ + public class BetterKeyBinding : KeyBinding, ICommand + { + public static readonly StyledProperty CustomCommandProperty = AvaloniaProperty.Register(nameof (CustomCommand)); + + public ICommand CustomCommand + { + get => GetValue(CustomCommandProperty); + set => SetValue(CustomCommandProperty, value); + } + + public BetterKeyBinding() + { + Command = this; + } + + public bool CanExecute(object? parameter) + { + return CustomCommand.CanExecute(parameter); + } + + public void Execute(object? parameter) + { + if (FocusManager.Instance.Current is TextBox tb) + { + var ev = new KeyEventArgs() + { + Key = Gesture.Key, + KeyModifiers = Gesture.KeyModifiers, + RoutedEvent = InputElement.KeyDownEvent + }; + tb.RaiseEvent(ev); + if (!ev.Handled) + CustomCommand.Execute(parameter); + } + else + CustomCommand.Execute(parameter); + } + + public event EventHandler? CanExecuteChanged + { + add => CustomCommand.CanExecuteChanged += value; + remove => CustomCommand.CanExecuteChanged -= value; + } + } +} \ No newline at end of file diff --git a/WDE.PacketViewer/Processing/ProcessorProviders/CreatureTextDumperProvider.cs b/WDE.PacketViewer/Processing/ProcessorProviders/CreatureTextDumperProvider.cs index 76b7e5766..3d08d1671 100644 --- a/WDE.PacketViewer/Processing/ProcessorProviders/CreatureTextDumperProvider.cs +++ b/WDE.PacketViewer/Processing/ProcessorProviders/CreatureTextDumperProvider.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Prism.Ioc; using WDE.Common.Types; using WDE.Module.Attributes; using WDE.PacketViewer.Processing.Processors; @@ -9,17 +10,34 @@ namespace WDE.PacketViewer.Processing.ProcessorProviders [AutoRegister] public class CreatureTextDumperProvider : IPacketDumperProvider { - private readonly Func creator; + private readonly IContainerProvider containerProvider; - public CreatureTextDumperProvider(Func creator) + public CreatureTextDumperProvider(IContainerProvider containerProvider) { - this.creator = creator; + this.containerProvider = containerProvider; } public string Name => "Creature text"; public string Description => "Generate all creature texts with emotes and sounds"; public string Extension => "sql"; public ImageUri? Image { get; } = new ImageUri("icons/chat_big.png"); public Task CreateDumper() => - Task.FromResult(creator()); + Task.FromResult(containerProvider.Resolve((typeof(bool), false))); + } + + [AutoRegister] + public class CreatureTextDiffDumperProvider : IPacketDumperProvider + { + private readonly IContainerProvider containerProvider; + + public CreatureTextDiffDumperProvider(IContainerProvider containerProvider) + { + this.containerProvider = containerProvider; + } + public string Name => "Creature text (diff)"; + public string Description => "Generate all creature texts with emotes and sounds, as a diff with current texts in the database"; + public string Extension => "sql"; + public ImageUri? Image { get; } = new ImageUri("icons/chat_diff_big.png"); + public Task CreateDumper() => + Task.FromResult(containerProvider.Resolve((typeof(bool), true))); } } \ No newline at end of file diff --git a/WDE.PacketViewer/Processing/Processors/CreatureTextDumper.cs b/WDE.PacketViewer/Processing/Processors/CreatureTextDumper.cs index 67e4325ab..ae821b942 100644 --- a/WDE.PacketViewer/Processing/Processors/CreatureTextDumper.cs +++ b/WDE.PacketViewer/Processing/Processors/CreatureTextDumper.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -10,20 +11,94 @@ namespace WDE.PacketViewer.Processing.Processors { public class CreatureTextDumper : PacketProcessor, ITwoStepPacketBoolProcessor, IPacketTextDumper { + private class TextEntry : IEquatable + { + public byte GroupId; + public byte Id; + public float Probability; + public CreatureTextRange Range; + public uint Duration; + public readonly CreatureTextType Type; + public readonly uint Language; + public readonly uint Emote; + public readonly uint Sound; + public readonly string Text; + public bool IsInSniffText { get; set; } + public bool IsInDatabaseText { get; set; } + public uint BroadcastTextId { get; set; } + + public string? Comment; + + public TextEntry(string text, CreatureTextType type, uint language, uint emote, uint sound) + { + Text = text; + Type = type; + Language = language; + Emote = emote; + Sound = sound; + Probability = 100; + Range = CreatureTextRange.Normal; + IsInSniffText = true; + IsInDatabaseText = false; + } + + public TextEntry(ICreatureText text) + { + IsInSniffText = false; + IsInDatabaseText = true; + Text = text.Text ?? ""; + Type = text.Type; + Language = text.Language; + Emote = text.Emote; + Sound = text.Sound; + GroupId = text.GroupId; + Probability = text.Probability; + Duration = text.Duration; + Range = text.TextRange; + Id = text.Id; + BroadcastTextId = text.BroadcastTextId; + Comment = text.Comment; + } + + public bool Equals(TextEntry? other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return Type == other.Type && Language == other.Language && Emote == other.Emote && Sound == other.Sound && Text == other.Text; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != this.GetType()) return false; + return Equals((TextEntry)obj); + } + + public override int GetHashCode() + { + return HashCode.Combine((int)Type, Language, Emote, Sound, Text); + } + } + private class State { - public Dictionary texts = new(); + public HashSet texts = new(); } private readonly IChatEmoteSoundProcessor chatEmoteSoundProcessor; private readonly IDatabaseProvider databaseProvider; + private readonly bool asDiff; private readonly Dictionary perEntryState = new(); - public CreatureTextDumper(IChatEmoteSoundProcessor chatEmoteSoundProcessor, IDatabaseProvider databaseProvider) + public CreatureTextDumper(IChatEmoteSoundProcessor chatEmoteSoundProcessor, + IDatabaseProvider databaseProvider, + bool asDiff) { this.chatEmoteSoundProcessor = chatEmoteSoundProcessor; this.databaseProvider = databaseProvider; + this.asDiff = asDiff; } private State GetState(UniversalGuid guid) @@ -44,20 +119,48 @@ protected override bool Process(PacketBase basePacket, PacketChat packet) var sound = chatEmoteSoundProcessor.GetSoundForChat(basePacket); var state = GetState(packet.Sender); - - if (state.texts.ContainsKey(packet.Text)) - return false; - - state.texts[packet.Text] = (state.texts.Count, packet.Type, packet.Language, emote ?? 0, sound ?? 0); - return true; + + var entry = new TextEntry(packet.Text, (CreatureTextType)packet.Type, (uint)packet.Language, (uint)(emote ?? 0), sound ?? 0) + { + GroupId = (byte)state.texts.Count, + Id = 0, + }; + return state.texts.Add(entry); } public async Task Generate() { var trans = Queries.BeginTransaction(); - trans.Comment("Warning!! This SQL will override current texts"); + if (!asDiff) + trans.Comment("Warning!! This SQL will override current texts"); foreach (var entry in perEntryState) { + int maxId = -1; + if (asDiff) + { + var existing = await databaseProvider.GetCreatureTextsByEntry(entry.Key); + foreach (var text in existing) + { + if (text.Text == null) + continue; + var databaseEntry = new TextEntry(text); + if (entry.Value.texts.TryGetValue(databaseEntry, out var sniffEntry)) + { + entry.Value.texts.Remove(sniffEntry); + databaseEntry.IsInSniffText = true; + } + entry.Value.texts.Add(databaseEntry); + maxId = Math.Max(maxId, text.GroupId); + } + } + + foreach (var sniffText in entry.Value.texts.Where(t => t.IsInSniffText && !t.IsInDatabaseText)) + { + sniffText.BroadcastTextId = + (await databaseProvider.GetBroadcastTextByTextAsync(sniffText.Text))?.Id ?? 0; + sniffText.GroupId = (byte)(++maxId); + } + var template = databaseProvider.GetCreatureTemplate(entry.Key); if (template != null) trans.Comment(template.Name); @@ -66,18 +169,24 @@ public async Task Generate() .Delete(); trans.Table("creature_text") .BulkInsert(entry.Value.texts + .OrderBy(t => t.GroupId) + .ThenBy(t => t.Id) .Select(text => new { CreatureID = entry.Key, - GroupID = text.Value.groupdId, - ID = 0, - Text = text.Key, - Type = text.Value.type, - Language = text.Value.language, - Probability = 100, - Emote = text.Value.emote, - Sound = text.Value.sound, - BroadcastTextId = databaseProvider.GetBroadcastTextByText(text.Key)?.Id ?? 0 + GroupID = text.GroupId, + ID = text.Id, + Text = text.Text, + Type = (uint)text.Type, + Language = text.Language, + Probability = text.Probability, + Duration = text.Duration, + TextRange = (uint)text.Range, + Emote = text.Emote, + Sound = text.Sound, + BroadcastTextId = text.BroadcastTextId, + comment = text.Comment ?? template?.Name ?? "", + __comment = !text.IsInSniffText ? "not in sniff" : null })); trans.BlankLine(); } diff --git a/WDE.PacketViewer/Processing/Processors/Utils/ChatEmoteSoundProcessor.cs b/WDE.PacketViewer/Processing/Processors/Utils/ChatEmoteSoundProcessor.cs index e5160ee63..c7ddf5804 100644 --- a/WDE.PacketViewer/Processing/Processors/Utils/ChatEmoteSoundProcessor.cs +++ b/WDE.PacketViewer/Processing/Processors/Utils/ChatEmoteSoundProcessor.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using WDE.Module.Attributes; using WowPacketParser.Proto; using WowPacketParser.Proto.Processing; @@ -93,19 +94,49 @@ protected override bool Process(PacketBase basePacket, PacketChat packet) protected override bool Process(PacketBase basePacket, PacketPlayObjectSound packet) { - Get(packet.Source).LastSound = (basePacket, packet.Sound); + var state = Get(packet.Source); + if (state.LastChat.HasValue && + !chatPacketIdToSound.ContainsKey(state.LastChat.Value.packet.Number) && + HasJustHappened(state.LastChat?.packet, basePacket)) + { + var chatId = state.LastChat!.Value.packet.Number; + chatPacketIdToSound[chatId] = packet.Sound; + soundPacketIdToChatPacketId[basePacket.Number] = chatId; + } + else + Get(packet.Source).LastSound = (basePacket, packet.Sound); return true; } protected override bool Process(PacketBase basePacket, PacketPlaySound packet) { - Get(packet.Source).LastSound = (basePacket, packet.Sound); + var state = Get(packet.Source); + if (state.LastChat.HasValue && + !chatPacketIdToSound.ContainsKey(state.LastChat.Value.packet.Number) && + HasJustHappened(state.LastChat?.packet, basePacket)) + { + var chatId = state.LastChat!.Value.packet.Number; + chatPacketIdToSound[chatId] = packet.Sound; + soundPacketIdToChatPacketId[basePacket.Number] = chatId; + } + else + Get(packet.Source).LastSound = (basePacket, packet.Sound); return true; } protected override bool Process(PacketBase basePacket, PacketEmote packet) { - Get(packet.Sender).LastEmote = (basePacket, packet.Emote); + var state = Get(packet.Sender); + if (state.LastChat.HasValue && + !chatPacketIdToEmote.ContainsKey(state.LastChat.Value.packet.Number) && + HasJustHappened(state.LastChat?.packet, basePacket)) + { + var chatId = state.LastChat!.Value.packet.Number; + chatPacketIdToEmote[chatId] = packet.Emote; + emotePacketIdToChatPacketId[basePacket.Number] = chatId; + } + else + state.LastEmote = (basePacket, packet.Emote); return true; } diff --git a/WoWDatabaseEditor/Icons/chat_diff.png b/WoWDatabaseEditor/Icons/chat_diff.png new file mode 100644 index 0000000000000000000000000000000000000000..2526bc956e6a97f9c1155b0ebd6bc7253e1c7249 GIT binary patch literal 865 zcmV-n1D^beP)C8TZ8HYkYN^jg$kP}`t&5EMla?Sj@tZAxzEs$fK(;mrBwH**d%XTTcHC|SMH z1KC_&3r+bP3z0=f^9!x?Vq*kvRMFLmU@$;k`@F|u##^^ptNyuv%@$AS3Rqf%#}!q} z3r`CN*7B;!JHp*cIvN)~6Lx8GLE$@7k5%~9cO zab`0GrFtLpCH6#aeokVacrQ93rtsllhDrR4GK5#=QQQnOC@bpZd&>ETnrAp7L`KK+bn8{{YO%XcYC9QeOZ7010qNS#tmYE+YT{E+YYWr9XB600D(bL_t(I zjm45bNCI&b$G_gM79B+H3gvfLNP|d$1Z_c6Ym3GWy(ucFtzBqxad7X3T5brbr6mx8 zgdkcXg$6;vyx$Od(o%@WUDs4#b(JlBxA%D8&-=pz{#${M2_ljhV>7ZW&j*9SwClPL zolfU!Fc{n!hVkSJAR-oz$G25gT}!1>bJ1ut6p2L8@AuK|cHL&PdEaO>F52z(p04X+ z2tY(EnM@vMGMN=cQ6#@{9mherTy9jWRjpVo-n;|y`TV}7X`A72_|r-NpiE5q?C%96 zB4VnlF8Y~&(ing#&bj(sk=5-C#h&Lu*Y!i>a*zPDgb?jfx|*hGTb6YK0Fq%CFQ#dp z_j*0|Cu8<`8QJG$)M~Z5Wm#tcAOS$VUf(U1N=HHn`!^FX#>N16!0;agIOiJYd@GSi z%*J9dSy2?pwry;TuYL)tOy35+ygU(2ghHXDEXxay<2+vPln+J%_-Uz?_5dJrx?T}N rToww2+$er5wbI@jgoyt1H=pzh<~5TUX&c0Q00000NkvXXu0mjf7hHw^ literal 0 HcmV?d00001 diff --git a/WoWDatabaseEditor/Icons/chat_diff@2x.png b/WoWDatabaseEditor/Icons/chat_diff@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a387e17510d5481368a8bc33d696f1c895d85d8b GIT binary patch literal 1279 zcmVC8TZ8HYkYN^jg$kP}`t&5EMla?Sj@tZAxzEs$fK(;mrBwH**d%XTTcHC|SMH z1KC_&3r+bP3z0=f^9!x?Vq*kvRMFLmU@$;k`@F|u##^^ptNyuv%@$AS3Rqf%#}!q} z3r`CN*7B;!JHp*cIvN)~6Lx8GLE$@7k5%~9cO zab`0GrFtLpCH6#aeokVacrQ93rtsllhDrR4GK5#=QQQnOC@bpZd&>ETnrAp7L`KK+bn8{{YO%XcYC9QeOZ7010qNS#tmYTt)x@Tt)#DltV!P00SdQL_t(o zh3%G4NE}xj$G`9GpP6~PqTudYCkhKSg49Ye;6bs9l7M=VM(E{1FHIna9J~~yz4Vep z>7@t3qt$mYg&q`(9(pKJB_4ukyD?S5_Tb;0vaGu=nb93zPixl2Kf7_YJ>&y(m><8H z?`Ixw-h04%-t!+rifQ7UdjZ@bgftq4(c<&@T3pv{P)b!$O8=phZa9v!V%zo~0M-Gl z2_e*#1vuxr-|v6m^Z7=i(Wq8eR~M+MsnJ5A5W?XwG);q)5|(8lkw{=~Z|`+H9)GjD zyPLFad&G6!pM?;|B@1xQgN9)YQ%WDVwY9OHo}NH37%b+4005R{VQy|NwZ6W7aCmt5 z?a|TEk3xuSQ39OvO2*i;&d$zgcXzj@>w3|_g``p`%+AirSSnwpw^T3uZ|H8)O1fOBpH0)d6TzP^t; zIy(HNhK0hBlKKf00RWmS7>4n6dwYBHyM$i_3ILpQKc)0xZ*Q-5li?=<06wa(uP2&z ztu6b^ zC(2ARne+hoyHMc8&d$zZCX>0=@OV59LdakF`ve65A%vUFX1|}Fo|Z}#wTE2Fki2{9 zQc7WJYATsdryu3_ovZ_nVSrLgu{NZpI zm6er9r_)HKQuwC+arp%bWFi0>2qBG(u@e< zx9kFi6bgO*%fobQYiq^$`1q@_v9S+x^{X9uN?>Ve>C3gXwPD+~Z|B!+002ovPDHLkV1nOEV!!|Z literal 0 HcmV?d00001 diff --git a/WoWDatabaseEditor/Icons/chat_diff_big.png b/WoWDatabaseEditor/Icons/chat_diff_big.png new file mode 100644 index 0000000000000000000000000000000000000000..a387e17510d5481368a8bc33d696f1c895d85d8b GIT binary patch literal 1279 zcmVC8TZ8HYkYN^jg$kP}`t&5EMla?Sj@tZAxzEs$fK(;mrBwH**d%XTTcHC|SMH z1KC_&3r+bP3z0=f^9!x?Vq*kvRMFLmU@$;k`@F|u##^^ptNyuv%@$AS3Rqf%#}!q} z3r`CN*7B;!JHp*cIvN)~6Lx8GLE$@7k5%~9cO zab`0GrFtLpCH6#aeokVacrQ93rtsllhDrR4GK5#=QQQnOC@bpZd&>ETnrAp7L`KK+bn8{{YO%XcYC9QeOZ7010qNS#tmYTt)x@Tt)#DltV!P00SdQL_t(o zh3%G4NE}xj$G`9GpP6~PqTudYCkhKSg49Ye;6bs9l7M=VM(E{1FHIna9J~~yz4Vep z>7@t3qt$mYg&q`(9(pKJB_4ukyD?S5_Tb;0vaGu=nb93zPixl2Kf7_YJ>&y(m><8H z?`Ixw-h04%-t!+rifQ7UdjZ@bgftq4(c<&@T3pv{P)b!$O8=phZa9v!V%zo~0M-Gl z2_e*#1vuxr-|v6m^Z7=i(Wq8eR~M+MsnJ5A5W?XwG);q)5|(8lkw{=~Z|`+H9)GjD zyPLFad&G6!pM?;|B@1xQgN9)YQ%WDVwY9OHo}NH37%b+4005R{VQy|NwZ6W7aCmt5 z?a|TEk3xuSQ39OvO2*i;&d$zgcXzj@>w3|_g``p`%+AirSSnwpw^T3uZ|H8)O1fOBpH0)d6TzP^t; zIy(HNhK0hBlKKf00RWmS7>4n6dwYBHyM$i_3ILpQKc)0xZ*Q-5li?=<06wa(uP2&z ztu6b^ zC(2ARne+hoyHMc8&d$zZCX>0=@OV59LdakF`ve65A%vUFX1|}Fo|Z}#wTE2Fki2{9 zQc7WJYATsdryu3_ovZ_nVSrLgu{NZpI zm6er9r_)HKQuwC+arp%bWFi0>2qBG(u@e< zx9kFi6bgO*%fobQYiq^$`1q@_v9S+x^{X9uN?>Ve>C3gXwPD+~Z|B!+002ovPDHLkV1nOEV!!|Z literal 0 HcmV?d00001 diff --git a/WoWDatabaseEditor/Icons/chat_diff_big@2x.png b/WoWDatabaseEditor/Icons/chat_diff_big@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0584f8c693e6537685c6fe180b56da1b12ab291d GIT binary patch literal 2025 zcmVC8TZ8HYkYN^jg$kP}`t&5EMla?Sj@tZAxzEs$fK(;mrBwH**d%XTTcHC|SMH z1KC_&3r+bP3z0=f^9!x?Vq*kvRMFLmU@$;k`@F|u##^^ptNyuv%@$AS3Rqf%#}!q} z3r`CN*7B;!JHp*cIvN)~6Lx8GLE$@7k5%~9cO zab`0GrFtLpCH6#aeokVacrQ93rtsllhDrR4GK5#=QQQnOC@bpZd&>ETnrAp7L`KK+bn8{{YO%XcYC9QeOZ7010qNS#tmYxQqY*xQqeJ_PZ|t00s?7L_t(| zob8%lXj4}l$3LgBhNNB0YCG#B&CIG3t4U?mR&Y!h8?rJcV}rrYM7FYl2otu!7#mD* zPo2ewZSIdlAI{Sl*81Sf{z$sIwcCsK50tgix)3c+<1|KE+upN>G^36A(=@qBt@*%# zOV00}e9xD2?>YDU4iqU;q)3q>MT!)8FcD@pE~R`3XaMSgN}yt5(NGL z29#3MX6l0+8z7~u1YQ8P&yqL?TR2Pbbn5A!Qfe}n zdLah}NGUDAbHE$GZeSUJ!{N|t*RB%ZntBz*|1nFbK01koaD}(J47N8 zu3x{-rAwD$MNdvn3IHL*Rju_2;J8w1Fk9U*7Hoi&vK)99cnf$GVEy{_Y~8w*+S*#2 zPG{EQ48ve-Y>fW?etLR(bSM;x>HQ7(3^=8f(%Jk+#s)|!1@M{>qE%~M?Q}YIV`HP( zwrv{~6&2ZNNr~2)ix)4_)6>KG^XK){)RX{z0p3wc^)IyDj0})cJ_&pdG}-NT-Q3(P znwpv@DY@q{Hwr>Cc1 z_jF!JV2uLYk0lxQmJnY-I@1D8aEGnxWF|~bxp3~=LiGrk*8-x%i>~_1cXV0FJe09DM zBs75NRA=A2cP}eetjMRHymQY4E~WGV&urPUh1%NM0!Yd)iB>QN0Cw)&nO`LZVvYe) z%140LJRT1&m#aXM@=N>(*bbC7G&B@KOF@V?pcY`&s#OJ$QW)ZAfprjFR1}J9|6I*! zdw##4Q>RV=>{Lqm~8M{n4rNtXCJ~cIk)>-W_EGovw z$77eb64l2WFk~2pxN#%n&+0|X<;$02m%WMV;|&-FxOVNDiIVcmi~$qO^evuv1O5UC z27~6R$|J#GPy>C*w%Fqh=n_JN&Ye4_4Z|>BoylCea)sNsZwuf;vY0srD5b`=)?b7| zAu%v8V7frFIeq$c?DFemF^RqgzXU!wd-iPUrcIj=!qntZ#=IPPHf8+}Wj_KiI5^1Q z;2?lfO8uEE{+^E^rPLU3Vq|25&dzyntt~1H!=R(1Llb@ZeV8gX*?#tWz)(j=haMRj z$+bSyxp3hEV`F0iIINUPxh;cz%K*^Yo1N~s^U){Vg5?d|Qfwzlfw;e;#8 zMZ@aVs|B!eCViijOp~MKzz4wlz;d6@M{{#?PWKPe&GXsff9(96J}+pMBc=2Jt-wnF z9*>8{#zuTTA6Bb1D}ncg0WqYMPXTWOF9X)n(o$VtUoY0JTZh~2rmCtcgwN-T{haihV96N(d=!a9_WJ$)TEGAQL{L%#mfSl)DfKC^o@k=?D