From 14e6f3ba5cb3e2bafc81f894c9d80797058bd321 Mon Sep 17 00:00:00 2001 From: Jay Long Date: Sun, 30 Nov 2025 12:14:23 -0600 Subject: [PATCH 1/2] Updating the ReadMe with the configuration elements --- README.md | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/README.md b/README.md index 783fd4d..2aa5c19 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,129 @@ Or if you have moved where the VR Chat ```output_log_*.txt``` are located; then: ```.\tailgrab.exe {full path to VR Chat logs ending with a \}``` +# Configuration + +## "Config.json" File + +The confiuration for TailGrab uses a JSON formated payload of the base attribute "lineHandlers" that contains a array of LineHandler Objects, Those may have a attribute of "actions" that contain an array of Action Objects. + +## LineHandler Definition + +The LineHandler defines what type of system action to perform, what regular expression to use to detect that type of log line and user actions to perform when detected. + +|Attribute | Definition | +|--------|--------| +| handlerTypeValue | An enumeration value of the internal LineHandler code segments. See ```handlerTypes``` | +| enabled | Boolean ```true``` or ```false``` to direct the application to include or temporarly ignore the configuration. | +| patternTypeValue | An enumeration value of ```default``` or ```override```; Default will use the programmer's defined default for the Regular Expression to match/extract and a Override will allow the user to fine tune or respond to VRChat application log changes with the attribute ```pattern``` | +| pattern | The Regular expression for the Pattern to match/extract, does nothing unless patternTypeValue is set to override | +| logOutput | Boolean ```true``` or ```false``` to direct the application to log the output of the Line Handler. | +| logOutputColor | A value of ```Default``` will use the programmers ANSI codes for the log output, if you use the last digits of the ANSI codes here, they are used. EG ```"37m"``` | +| actions | A array of Action Configuration elements or do nothing by leaving it as an empty array ```[]``` | + +## actionTypeValue Enum Values + +|actionTypeValue | Definition | +|--------|--------| +| DelayAction | Delay a defined amount of time before next action. | +| OSCAction | Send OSC Avatar Parameter values to your VRChat Avatar. | +| KeyPressAction | Send Keystrokes to a named open window title on your system. | + + +## Action: DelayAction Definition + +The Delay Action will allow you to pause other actions with millisecond precision. If you need to pause for 1 second, use 1000 as the delay time. This action is used when there is a need for a sound trigger to play or you want to send stacked keystrokes to an application that is running. + +|Attribute | Definition | +|--------|--------| +| actionTypeValue | An enumeration value of the internal LineHandler code segments. See ```actionTypeValue``` | +| milliseconds | integer value of milliseconds to wait for. | + +## Action: OSCAction Definition + +The OSC Action will allow you to send values (```Float```/```Int```/```Bool```) to your VRChat avatar that could be used to trigger animations on it during a action set. + +|Attribute | Definition | +|--------|--------| +| actionTypeValue | ```OSCAction``` See ```actionTypeValue``` | +| parameterName | The VRChat Avatar Parameter Path to send to; EG. ```/avatar/parameters/Ear/Right_Angle``` | +| oscValueType | OSC Value types associated with that Parameter Path; ```Float``` or ```Int``` or ```Bool``` | +| value | The Value to send to your avatar; Floats expect a decimal place ```0.0```, Int expect no decimal place ```0```, and Bool expects either ```true``` or ```false``` + +## Action : KeyPressAction Definition + +The KeyPress action will let you send keystrokes to a targed application by it's HWND Window Title, if the application runs windowless/without a title bar, this may not work for you. + +|Attribute | Definition | +|--------|--------| +| actionTypeValue | ```KeyPressAction``` See ```actionTypeValue``` | +| windowTitle | Windows application title; EG. ```VRChat``` | +| keys | An encoded defintion of keys to send to the application; see below | + + +From https://learn.microsoft.com/en-us/dotnet/api/system.windows.forms.sendkeys?view=windowsdesktop-10.0 + +The plus sign (```+```), caret (```^```), percent sign (```%```), tilde (```~```), and parentheses ```()``` have special meanings to SendKeys. To specify one of these characters, enclose it within braces ```({})```. For example, to specify the plus sign, use "{+}". To specify brace characters, use ```"{{}"``` and ```"{}}"```. Brackets ```([ ])``` have no special meaning to SendKeys, but you must enclose them in braces. In other applications, brackets do have a special meaning that might be significant when dynamic data exchange (DDE) occurs. + +To specify characters that aren't displayed when you press a key, such as ```ENTER``` or ```TAB```, and keys that represent actions rather than characters, use the codes in the following table. + +### Key Encoding +|Key Desired | Key Encoding | +|--------|--------| +|BACKSPACE | {BACKSPACE}, {BS}, or {BKSP} | +|BREAK | {BREAK} | +|CAPS LOCK | {CAPSLOCK} | +|DEL or DELETE | {DELETE} or {DEL} | +|DOWN ARROW|{DOWN}| +|END | {END} +|ENTER | {ENTER} or ~ +|ESC | {ESC} +|HELP | {HELP} +|HOME | {HOME} +|INS or INSERT | {INSERT} or {INS} +|LEFT ARROW | {LEFT} +|NUM LOCK | {NUMLOCK} +|PAGE DOWN | {PGDN} +|PAGE UP | {PGUP} +|PRINT SCREEN | {PRTSC} (reserved for future use) +|RIGHT ARROW | {RIGHT} +|SCROLL LOCK | {SCROLLLOCK} +|TAB | {TAB} +|UP ARROW | {UP} +|F1 | {F1} +|F2 | {F2} +|F3 | {F3} +|F4 | {F4} +|F5 | {F5} +|F6 | {F6} +|F7 | {F7} +|F8 | {F8} +|F9 | {F9} +|F10 | {F10} +|F11 | {F11} +|F12 | {F12} +|F13 | {F13} +|F14 | {F14} +|F15 | {F15} +|F16 | {F16} +|Keypad add | {ADD} +|Keypad subtract | {SUBTRACT} +|Keypad multiply | {MULTIPLY} +|Keypad divide | {DIVIDE} + +To specify keys combined with any combination of the SHIFT, CTRL, and ALT keys, precede the key code with one or more of the following codes. + +|Key Desired | Key Encoding | +|--------|--------| +|SHIFT | + | +|CTRL | ^ | +|ALT | % | + +To specify that any combination of SHIFT, CTRL, and ALT should be held down while several other keys are pressed, enclose the code for those keys in parentheses. For example, to specify to hold down SHIFT while E and C are pressed, use ```"+(EC)"```. To specify to hold down SHIFT while E is pressed, followed by C without SHIFT, use ```"+EC"```. + +To specify repeating keys, use the form ```{key number}```. You must put a space between key and number. For example, ```{LEFT 42}``` means press the LEFT ARROW key 42 times; ```{h 10}``` means press H 10 times. + + # Capabilities From 365339f2ec0c8ae37e53e315c41124e6a547077c Mon Sep 17 00:00:00 2001 From: Jay Long Date: Wed, 3 Dec 2025 15:11:15 -0600 Subject: [PATCH 2/2] Introduction of the GUI and attempts to get KeyStrokeSend to work --- .gitignore | 4 +- Actions/Actions.cs | 303 +++++++++++++++++++- LineHandlers/PenNetworkIdHandler.cs | 5 +- PlayerManagement/PlayerManagement.cs | 72 +++++ PlayerManagement/TailgrabPannel.xaml | 182 ++++++++++++ PlayerManagement/TailgrabPannel.xaml.cs | 354 ++++++++++++++++++++++++ Program.cs | 135 ++++++++- Resources/tailgrab.ico | Bin 0 -> 16958 bytes Resources/tailgrab.png | 1 + config.json | 44 +-- configuration/ConfigurationManager.cs | 137 ++++++--- tailgrab.csproj | 23 +- tailgrab.sln | 4 +- 13 files changed, 1189 insertions(+), 75 deletions(-) create mode 100644 PlayerManagement/TailgrabPannel.xaml create mode 100644 PlayerManagement/TailgrabPannel.xaml.cs create mode 100644 Resources/tailgrab.ico create mode 100644 Resources/tailgrab.png diff --git a/.gitignore b/.gitignore index 35063fc..51be0f2 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,6 @@ CodeCoverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +nunit-*.xml +.vs +.gitignore diff --git a/Actions/Actions.cs b/Actions/Actions.cs index 843c259..39bd8ca 100644 --- a/Actions/Actions.cs +++ b/Actions/Actions.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using BuildSoft.VRChat.Osc; using BuildSoft.VRChat.Osc.Avatar; +using NLog; namespace Tailgrab.Actions { @@ -13,10 +14,13 @@ public interface IAction public class DelayAction : IAction { public int DelayMilliseconds { get; set; } + public Logger logger = LogManager.GetCurrentClassLogger(); public DelayAction(int delayMilliseconds) { DelayMilliseconds = delayMilliseconds; + logger.Warn($"Added DelayAction: Will delay for : '{DelayMilliseconds}' milliseconds."); + } public void PerformAction() @@ -39,26 +43,122 @@ public class KeystrokesAction : IAction [DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint nInputs, [In] INPUT[] pInputs, int cbSize); + // Additional native methods for reliably setting foreground focus + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("kernel32.dll")] + private static extern uint GetCurrentThreadId(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + + [DllImport("user32.dll")] + private static extern bool BringWindowToTop(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + private static extern IntPtr SetFocus(IntPtr hWnd); + private const uint INPUT_KEYBOARD = 1; private const uint KEYEVENTF_KEYUP = 0x0002; private const uint KEYEVENTF_UNICODE = 0x0004; + private const int SW_RESTORE = 9; + + public Logger logger = LogManager.GetCurrentClassLogger(); + + public string WindowTitle { get; set; } + public string Keys { get; set; } + + public KeystrokesAction(string windowTitle, string keys) + { + WindowTitle = windowTitle; + Keys = keys; - public required string WindowTitle { get; set; } - public required string Keys { get; set; } + logger.Warn($"Added KeystrokesAction: Window Title: '{WindowTitle}' with Keys: {Keys}."); + } public void PerformAction() { if( WindowTitle == null || Keys == null ) { + logger.Warn($"KeystrokesAction: Window Title: '{WindowTitle}' or Keys: {Keys} not supplied."); return; } - var processes = System.Diagnostics.Process.GetProcessesByName(WindowTitle); - if (processes.Length > 0) + System.Diagnostics.Process? targetProcess = null; + + try + { + var all = System.Diagnostics.Process.GetProcesses(); + foreach (var p in all) + { + try + { + if (p.MainWindowHandle == System.IntPtr.Zero) continue; + var title = p.MainWindowTitle; + if (string.IsNullOrEmpty(title)) continue; + if (title.IndexOf(WindowTitle, StringComparison.CurrentCultureIgnoreCase) >= 0) + { + targetProcess = p; + break; + } + } + catch + { + // Access denied or process exited -- ignore and continue + } + } + + // Fallback: try by process name if no title match + if (targetProcess == null) + { + var byName = System.Diagnostics.Process.GetProcessesByName(WindowTitle); + if (byName.Length > 0) targetProcess = byName[0]; + } + + if (targetProcess != null) + { + IntPtr handle = targetProcess.MainWindowHandle; + + logger.Debug($"KeystrokesAction: Sending to process '{targetProcess.ProcessName}' (PID {targetProcess.Id}) Title: '{targetProcess.MainWindowTitle}' Keys: {Keys}."); + + // Attempt to reliably bring the target window to the foreground + uint targetThread = GetWindowThreadProcessId(handle, out _); + uint currentThread = GetCurrentThreadId(); + bool attached = false; + + try + { + // Attach input threads so SetForegroundWindow works reliably + attached = AttachThreadInput(currentThread, targetThread, true); + + ShowWindow(handle, SW_RESTORE); + BringWindowToTop(handle); + SetForegroundWindow(handle); + SetFocus(handle); + + // Use SendInput with unicode characters for reliable keystroke delivery + SendUnicodeString(Keys); + } + finally + { + if (attached) + { + AttachThreadInput(currentThread, targetThread, false); + } + } + } + else + { + logger.Warn($"KeystrokesAction: Window with title containing '{WindowTitle}' not found."); + } + } + catch (Exception ex) { - IntPtr handle = processes[0].MainWindowHandle; - SetForegroundWindow(handle); - SendUnicodeString(Keys); + logger.Error(ex, "KeystrokesAction: Error while attempting to find target process/window"); } } @@ -70,6 +170,190 @@ private void SendUnicodeString(string s) } } + // New: parse SendKeys-style notation and send via SendInput. + // Supports modifiers '^' (Ctrl), '%' (Alt), '+' (Shift), grouping with '()' and braced keys like '{ENTER}', '{F1}', etc. + private void SendKeysNotation(string keys) + { + if (string.IsNullOrEmpty(keys)) return; + + for (int i = 0; i < keys.Length; i++) + { + char c = keys[i]; + + // Handle modifiers prefixing a token + if (c == '^' || c == '%' || c == '+') + { + var mods = new List(); + // collect consecutive modifier symbols + while (i < keys.Length && (keys[i] == '^' || keys[i] == '%' || keys[i] == '+')) + { + if (keys[i] == '^') mods.Add(0x11); // VK_CONTROL + if (keys[i] == '%') mods.Add(0x12); // VK_MENU (Alt) + if (keys[i] == '+') mods.Add(0x10); // VK_SHIFT + i++; + } + + if (i >= keys.Length) break; + + // Determine the token: group '(... )', braced '{...}', or single char + if (keys[i] == '(') + { + int start = i + 1; + int end = keys.IndexOf(')', start); + if (end == -1) end = keys.Length - 1; + string group = keys.Substring(start, end - start); + foreach (var ch in group) + { + SendCharWithModifiers(ch, mods); + } + i = end; + } + else if (keys[i] == '{') + { + int start = i + 1; + int end = keys.IndexOf('}', start); + if (end == -1) end = keys.Length - 1; + string token = keys.Substring(start, end - start); + SendTokenWithModifiers(token, mods); + i = end; + } + else + { + SendCharWithModifiers(keys[i], mods); + } + + continue; + } + + // Braced token or single character + if (c == '{') + { + int start = i + 1; + int end = keys.IndexOf('}', start); + if (end == -1) end = keys.Length - 1; + string token = keys.Substring(start, end - start); + SendTokenWithModifiers(token, new List()); + i = end; + } + else + { + // Regular char + SendUnicodeChar(c); + } + } + } + + private void SendCharWithModifiers(char ch, List mods) + { + // press mods + foreach (var m in mods) + { + SendVirtualKeyDown(m); + } + + // send character + SendUnicodeChar(ch); + + // release mods in reverse order + for (int j = mods.Count - 1; j >= 0; j--) + { + SendVirtualKeyUp(mods[j]); + } + } + + private void SendTokenWithModifiers(string token, List mods) + { + // Normalize token + var t = token.ToUpperInvariant(); + + // Mapping of common SendKeys tokens to virtual-key codes + Dictionary map = new Dictionary + { + {"ENTER", 0x0D}, + {"TAB", 0x09}, + {"BACKSPACE", 0x08}, + {"BS", 0x08}, + {"BKSP", 0x08}, + {"ESC", 0x1B}, + {"LEFT", 0x25}, + {"UP", 0x26}, + {"RIGHT", 0x27}, + {"DOWN", 0x28}, + {"HOME", 0x24}, + {"END", 0x23}, + {"PGUP", 0x21}, + {"PRIOR", 0x21}, + {"PGDN", 0x22}, + {"NEXT", 0x22}, + {"INSERT", 0x2D}, + {"DELETE", 0x2E}, + {"DEL", 0x2E}, + {"SPACE", 0x20}, + {"F1", 0x70}, {"F2", 0x71}, {"F3", 0x72}, {"F4", 0x73}, {"F5", 0x74}, + {"F6", 0x75}, {"F7", 0x76}, {"F8", 0x77}, {"F9", 0x78}, {"F10", 0x79}, + {"F11", 0x7A}, {"F12", 0x7B}, + {"LWIN", 0x5B}, {"RWIN", 0x5C}, {"APPS", 0x5D} + }; + + if (map.TryGetValue(t, out ushort vk)) + { + // press mods + foreach (var m in mods) SendVirtualKeyDown(m); + + // send vk + SendVirtualKey(vk); + + // release mods + for (int j = mods.Count - 1; j >= 0; j--) SendVirtualKeyUp(mods[j]); + } + else + { + // If token length == 1, send that character + if (token.Length == 1) + { + SendCharWithModifiers(token[0], mods); + } + else + { + // For unknown tokens, attempt to send the text literally + foreach (var ch in token) + { + SendCharWithModifiers(ch, mods); + } + } + } + } + + private void SendVirtualKeyDown(ushort vk) + { + INPUT[] inputs = new INPUT[1]; + inputs[0].type = INPUT_KEYBOARD; + inputs[0].U.ki.wVk = vk; + inputs[0].U.ki.wScan = 0; + inputs[0].U.ki.dwFlags = 0; + inputs[0].U.ki.time = 0; + inputs[0].U.ki.dwExtraInfo = System.IntPtr.Zero; + SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT))); + } + + private void SendVirtualKeyUp(ushort vk) + { + INPUT[] inputs = new INPUT[1]; + inputs[0].type = INPUT_KEYBOARD; + inputs[0].U.ki.wVk = vk; + inputs[0].U.ki.wScan = 0; + inputs[0].U.ki.dwFlags = KEYEVENTF_KEYUP; + inputs[0].U.ki.time = 0; + inputs[0].U.ki.dwExtraInfo = System.IntPtr.Zero; + SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT))); + } + + private void SendVirtualKey(ushort vk) + { + SendVirtualKeyDown(vk); + SendVirtualKeyUp(vk); + } + private void SendUnicodeChar(char ch) { INPUT[] inputs = new INPUT[2]; @@ -138,9 +422,9 @@ private struct HARDWAREINPUT } - public class OSCAction : IAction { + public Logger logger = LogManager.GetCurrentClassLogger(); private OscAvatarConfig? oscAvatarConfig = OscAvatarConfig.CreateAtCurrent(); @@ -155,6 +439,9 @@ public OSCAction(string parameterName, OscType type, string value) ParameterName = parameterName; OscTypeValue = type; Value = value; + + logger.Warn($"Added OSCAction: Parameter: '{ParameterName}'; Type: {OscTypeValue}; Value: {Value}."); + } public void PerformAction() diff --git a/LineHandlers/PenNetworkIdHandler.cs b/LineHandlers/PenNetworkIdHandler.cs index d1fb505..aa8e6f8 100644 --- a/LineHandlers/PenNetworkIdHandler.cs +++ b/LineHandlers/PenNetworkIdHandler.cs @@ -1,9 +1,10 @@ -namespace Tailgrab.LineHandler; - +using System.IO; using System.Text; using System.Text.RegularExpressions; using Tailgrab.PlayerManagement; +namespace Tailgrab.LineHandler; + public class PenNetworkHandler : AbstractLineHandler { diff --git a/PlayerManagement/PlayerManagement.cs b/PlayerManagement/PlayerManagement.cs index 25bfdf9..1a593b0 100644 --- a/PlayerManagement/PlayerManagement.cs +++ b/PlayerManagement/PlayerManagement.cs @@ -30,6 +30,7 @@ public class Player { public string UserId { get; set; } public string DisplayName { get; set; } + public string AvatarName { get; set; } public int NetworkId { get; set; } public DateTime InstanceStartTime { get; set; } public DateTime? InstanceEndTime { get; set; } @@ -39,6 +40,7 @@ public Player(string userId, string displayName) { UserId = userId; DisplayName = displayName; + AvatarName = ""; InstanceStartTime = DateTime.Now; } @@ -48,6 +50,26 @@ public void AddEvent(PlayerEvent playerEvent) } } + public class PlayerChangedEventArgs : EventArgs + { + public enum ChangeType + { + Added, + Updated, + Removed, + Cleared + } + + public ChangeType Type { get; } + public Player Player { get; } + + public PlayerChangedEventArgs(ChangeType type, Player player) + { + Type = type; + Player = player; + } + } + public static class PlayerManager { private static Dictionary playersByNetworkId = new Dictionary(); @@ -59,6 +81,21 @@ public static class PlayerManager public static readonly string COLOR_RESET = "\u001b[0m"; public static Logger logger = LogManager.GetCurrentClassLogger(); + // Event for UI and other listeners + public static event EventHandler? PlayerChanged; + + private static void OnPlayerChanged(PlayerChangedEventArgs.ChangeType changeType, Player player) + { + try + { + PlayerChanged?.Invoke(null, new PlayerChangedEventArgs(changeType, player)); + } + catch (Exception ex) + { + logger.Error(ex, "Error raising PlayerChanged event"); + } + } + public static void PlayerJoined(string userId, string displayName, AbstractLineHandler handler) { if (!playersByUserId.ContainsKey(userId)) @@ -73,12 +110,32 @@ public static void PlayerJoined(string userId, string displayName, AbstractLineH if( avatarByDisplayName.TryGetValue(displayName, out string? avatarName)) { + SetAvatarForPlayer(displayName, avatarName); AddPlayerEventByDisplayName(displayName, PlayerEvent.EventType.AvatarChange, $"Joined with Avatar: {avatarName}"); if( handler.LogOutput ) { logger.Info($"{COLOR_PREFIX_GREEN}\tAvatar on Join: {avatarName}{COLOR_RESET}"); } } + + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Added, newPlayer); + } + else + { + // If existing, treat as update (display name may have changed etc.) + var existing = playersByUserId[userId]; + if (existing.DisplayName != displayName) + { + // remove old display-name mapping if present + if (!string.IsNullOrEmpty(existing.DisplayName)) + { + playersByDisplayName.Remove(existing.DisplayName); + } + + existing.DisplayName = displayName; + playersByDisplayName[displayName] = existing; + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, existing); + } } } @@ -87,6 +144,9 @@ public static void PlayerLeft(string displayName, AbstractLineHandler handler ) if (playersByDisplayName.TryGetValue(displayName, out Player? player)) { player.InstanceEndTime = DateTime.Now; + // Raise event with updated player before removing from internal dictionaries + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Removed, player); + playersByDisplayName.Remove(displayName); playersByNetworkId.Remove(player.NetworkId); playersByUserId.Remove(player.UserId); @@ -121,6 +181,7 @@ public static void PlayerLeft(string displayName, AbstractLineHandler handler ) { player.NetworkId = networkId; playersByNetworkId[networkId] = player; + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); } return player; @@ -141,11 +202,16 @@ public static void ClearAllPlayers(AbstractLineHandler handler) { PrintPlayerInfo(player); } + // Notify removed for each player + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Removed, player); } playersByNetworkId.Clear(); playersByUserId.Clear(); playersByDisplayName.Clear(); + + // Also a global cleared notification (consumers may want to reset) + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Cleared, new Player("","") { InstanceStartTime = DateTime.MinValue }); } public static int GetPlayerCount() @@ -170,6 +236,7 @@ public static void LogAllPlayers(AbstractLineHandler handler) { PlayerEvent newEvent = new PlayerEvent(eventType, eventDescription); player.AddEvent(newEvent); + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, player); return player; } @@ -179,6 +246,11 @@ public static void LogAllPlayers(AbstractLineHandler handler) public static void SetAvatarForPlayer(string displayName, string avatarId) { avatarByDisplayName[displayName] = avatarId; + if (playersByDisplayName.TryGetValue(displayName, out var p)) + { + p.AvatarName = avatarId; + OnPlayerChanged(PlayerChangedEventArgs.ChangeType.Updated, p); + } } private static void PrintPlayerInfo(Player player) diff --git a/PlayerManagement/TailgrabPannel.xaml b/PlayerManagement/TailgrabPannel.xaml new file mode 100644 index 0000000..ddbc8f4 --- /dev/null +++ b/PlayerManagement/TailgrabPannel.xaml @@ -0,0 +1,182 @@ + + + + + + + + + + + + +