From 8ebdcecc15c4e737c8e08e70d89b33bc17402833 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Tue, 26 May 2026 22:37:39 +0200 Subject: [PATCH 1/9] custom, avatar paramters, fix cooldown bug --- MigrationInstaller/MigrationInstaller.csproj | 2 +- ShockOsc/Config/AvatarParameterAction.cs | 32 ++ ShockOsc/Config/ShockOscConfig.cs | 6 + ShockOsc/Services/OscHandler.cs | 8 +- ShockOsc/Services/ShockOsc.cs | 55 ++++ ShockOsc/ShockOSCModule.cs | 6 + ShockOsc/ShockOsc.csproj | 16 +- .../Ui/Pages/Dash/Tabs/AvatarActionsTab.razor | 302 ++++++++++++++++++ ShockOsc/Ui/Pages/Dash/Tabs/ChatboxTab.razor | 2 +- ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor | 5 +- copy-module-dll.cmd | 1 - 11 files changed, 424 insertions(+), 11 deletions(-) create mode 100644 ShockOsc/Config/AvatarParameterAction.cs create mode 100644 ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor delete mode 100644 copy-module-dll.cmd diff --git a/MigrationInstaller/MigrationInstaller.csproj b/MigrationInstaller/MigrationInstaller.csproj index 0e058c0..17c628a 100644 --- a/MigrationInstaller/MigrationInstaller.csproj +++ b/MigrationInstaller/MigrationInstaller.csproj @@ -2,7 +2,7 @@ Exe - net9.0-windows + net10.0-windows enable enable true diff --git a/ShockOsc/Config/AvatarParameterAction.cs b/ShockOsc/Config/AvatarParameterAction.cs new file mode 100644 index 0000000..ae9c8b3 --- /dev/null +++ b/ShockOsc/Config/AvatarParameterAction.cs @@ -0,0 +1,32 @@ +using OpenShock.Desktop.ModuleBase.Models; + +namespace OpenShock.ShockOSC.Config; + +public sealed class AvatarParameterAction +{ + public required string ParameterName { get; set; } + public ControlType Action { get; set; } = ControlType.Shock; + public Guid GroupId { get; set; } + public ParameterTriggerKind TriggerKind { get; set; } = ParameterTriggerKind.OnTrue; + public float Threshold { get; set; } = 0.5f; + public byte? OverrideIntensity { get; set; } + public ushort? OverrideDuration { get; set; } +} + +public enum ParameterTriggerKind +{ + /// + /// Triggers when a bool parameter becomes true + /// + OnTrue, + + /// + /// Triggers when a float parameter exceeds the threshold + /// + Threshold, + + /// + /// Triggers on any value change (non-default) + /// + OnChange +} diff --git a/ShockOsc/Config/ShockOscConfig.cs b/ShockOsc/Config/ShockOscConfig.cs index 82729aa..7d97753 100644 --- a/ShockOsc/Config/ShockOscConfig.cs +++ b/ShockOsc/Config/ShockOscConfig.cs @@ -9,6 +9,12 @@ public sealed class ShockOscConfig public ChatboxConf Chatbox { get; set; } = new(); public IDictionary Groups { get; set; } = new Dictionary(); + /// + /// Custom parameter-to-action mappings, keyed by avatar ID + /// + public IDictionary> AvatarParameterActions { get; set; } = + new Dictionary>(); + public T GetGroupOrGlobal(ProgramGroup group, Func selector, Func groupOverrideSelector) { diff --git a/ShockOsc/Services/OscHandler.cs b/ShockOsc/Services/OscHandler.cs index f78b0bf..6fa252d 100644 --- a/ShockOsc/Services/OscHandler.cs +++ b/ShockOsc/Services/OscHandler.cs @@ -84,9 +84,13 @@ public async Task SendParams() foreach (var shocker in _shockOscData.ProgramGroups.Values) { + var cooldownTime = _moduleConfig.Config.Behaviour.CooldownTime; + if (shocker.ConfigGroup is { OverrideCooldownTime: true }) + cooldownTime = shocker.ConfigGroup.CooldownTime; + var isActive = shocker.LastExecuted.AddMilliseconds(shocker.LastDuration) > DateTime.UtcNow; var isActiveOrOnCooldown = - shocker.LastExecuted.AddMilliseconds(_moduleConfig.Config.Behaviour.CooldownTime) + shocker.LastExecuted.AddMilliseconds(cooldownTime) .AddMilliseconds(shocker.LastDuration) > DateTime.UtcNow; if (!isActiveOrOnCooldown && shocker.LastIntensity > 0) shocker.LastIntensity = 0; @@ -100,7 +104,7 @@ public async Task SendParams() shocker.LastExecuted.AddMilliseconds( shocker.LastDuration)) .TotalMilliseconds / - _moduleConfig.Config.Behaviour.CooldownTime); + cooldownTime); await shocker.ParamActive.SetValue(isActive); await shocker.ParamCooldown.SetValue(onCoolDown); diff --git a/ShockOsc/Services/ShockOsc.cs b/ShockOsc/Services/ShockOsc.cs index bd9ce4e..3a80159 100644 --- a/ShockOsc/Services/ShockOsc.cs +++ b/ShockOsc/Services/ShockOsc.cs @@ -61,6 +61,11 @@ public sealed class ShockOsc public readonly Dictionary ShockOscParams = new(); public readonly Dictionary AllAvatarParams = new(); + /// + /// Tracks previous values of avatar parameters for custom action trigger detection + /// + private readonly Dictionary _previousParamValues = new(); + public IObservable OnParamsChangeObservable => _onParamsChange; private readonly Subject _onParamsChange = new(); @@ -159,6 +164,7 @@ private Task OnAvatarChange(OscQueryServer.ParameterUpdateArgs parameterUpdateAr ShockOscParams.Clear(); AllAvatarParams.Clear(); + _previousParamValues.Clear(); foreach (var param in parameters.Keys) { @@ -201,6 +207,51 @@ private Task OnAvatarChange(OscQueryServer.ParameterUpdateArgs parameterUpdateAr return Task.CompletedTask; } + private void CheckCustomParameterAction(string paramName, object? oldValue, object? newValue) + { + if (string.IsNullOrEmpty(AvatarId)) return; + if (!_moduleConfig.Config.AvatarParameterActions.TryGetValue(AvatarId, out var actions)) return; + + foreach (var action in actions) + { + if (action.ParameterName != paramName) continue; + + var shouldTrigger = action.TriggerKind switch + { + ParameterTriggerKind.OnTrue => newValue is true && oldValue is not true, + ParameterTriggerKind.Threshold => newValue is float f && f >= action.Threshold && + (oldValue is not float oldF || oldF < action.Threshold), + ParameterTriggerKind.OnChange => !Equals(newValue, oldValue) && newValue is not null && + newValue is not false && newValue is not 0 && newValue is not 0f, + _ => false + }; + + if (!shouldTrigger) continue; + + if (!_dataLayer.ProgramGroups.TryGetValue(action.GroupId, out var programGroup)) + { + _logger.LogWarning("Custom parameter action references unknown group {GroupId}", action.GroupId); + continue; + } + + if (!CheckAndSetAllPreconditions(programGroup).IsT0) + { + _logger.LogDebug("Custom parameter action skipped due to preconditions for group {Group}", + programGroup.Name); + continue; + } + + var intensity = action.OverrideIntensity ?? GetIntensity(programGroup); + var duration = action.OverrideDuration ?? GetDuration(programGroup); + + _logger.LogInformation( + "Custom parameter action triggered: {Param} -> {Action} on group {Group} (intensity: {Intensity}, duration: {Duration}ms)", + paramName, action.Action, programGroup.Name, intensity, duration); + + OsTask.Run(() => SendCommand(programGroup, duration, intensity, action.Action)); + } + } + private async Task ReceiverLoopAsync() { while (_oscServerActive) @@ -236,11 +287,15 @@ private async Task ReceiveLogic() { // FIXME: less alloc pls var fullName = addr[19..]; + var oldValue = AllAvatarParams.GetValueOrDefault(fullName); if (AllAvatarParams.ContainsKey(fullName)) AllAvatarParams[fullName] = received.Arguments[0]; else AllAvatarParams.TryAdd(fullName, received.Arguments[0]); _onParamsChange.OnNext(false); + + // Check custom avatar parameter actions + CheckCustomParameterAction(fullName, oldValue, received.Arguments[0]); } switch (addr) diff --git a/ShockOsc/ShockOSCModule.cs b/ShockOsc/ShockOSCModule.cs index cbf4851..8a97229 100644 --- a/ShockOsc/ShockOSCModule.cs +++ b/ShockOsc/ShockOSCModule.cs @@ -37,6 +37,12 @@ public sealed class ShockOSCModule : DesktopModuleBase, IAsyncDisposable Icon = IconOneOf.FromSvg(Icons.Material.Filled.Group) }, new() + { + Name = "Avatar Actions", + ComponentType = typeof(AvatarActionsTab), + Icon = IconOneOf.FromSvg(Icons.Material.Filled.Tune) + }, + new() { Name = "Chatbox", ComponentType = typeof(ChatboxTab), diff --git a/ShockOsc/ShockOsc.csproj b/ShockOsc/ShockOsc.csproj index 0a7aafa..0b02ef7 100644 --- a/ShockOsc/ShockOsc.csproj +++ b/ShockOsc/ShockOsc.csproj @@ -13,6 +13,7 @@ 3.1.0 3.1.0 + openshock.shockosc ShockOsc en @@ -22,18 +23,18 @@ Debug;Release AnyCPU - net9.0 + net10.0 10.0.17763.0 10.0.17763.0 - - + + - + @@ -51,5 +52,12 @@ + + + $(APPDATA)\OpenShock\Desktop\modules\$(OpenShockModuleId)\ + + + + diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor new file mode 100644 index 0000000..9744175 --- /dev/null +++ b/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor @@ -0,0 +1,302 @@ +@using OpenShock.Desktop.ModuleBase +@using OpenShock.Desktop.ModuleBase.Api +@using OpenShock.Desktop.ModuleBase.Config +@using OpenShock.Desktop.ModuleBase.Models +@using OpenShock.ShockOSC.Config +@using OpenShock.ShockOSC.Services +@using OpenShock.ShockOSC.Ui.Utils + +@page "/dash/avatar-actions" + +@code { + + [ModuleInject] private IModuleConfig ModuleConfig { get; set; } = null!; + [ModuleInject] private ShockOsc ShockOsc { get; set; } = null!; + [ModuleInject] private IOpenShockService OpenShock { get; set; } = null!; + + private string CurrentAvatarId => ShockOsc.AvatarId; + + private IList CurrentActions + { + get + { + if (string.IsNullOrEmpty(CurrentAvatarId)) return []; + if (!ModuleConfig.Config.AvatarParameterActions.TryGetValue(CurrentAvatarId, out var actions)) + return []; + return actions; + } + } + + private AvatarParameterAction? _selectedAction; + private string _parameterSearch = ""; + + private void AddAction() + { + if (string.IsNullOrEmpty(CurrentAvatarId)) return; + + if (!ModuleConfig.Config.AvatarParameterActions.ContainsKey(CurrentAvatarId)) + ModuleConfig.Config.AvatarParameterActions[CurrentAvatarId] = new List(); + + var action = new AvatarParameterAction + { + ParameterName = "", + Action = ControlType.Shock, + TriggerKind = ParameterTriggerKind.OnTrue + }; + + ModuleConfig.Config.AvatarParameterActions[CurrentAvatarId].Add(action); + _selectedAction = action; + ModuleConfig.SaveDeferred(); + InvokeAsync(StateHasChanged); + } + + private void RemoveAction(AvatarParameterAction action) + { + if (string.IsNullOrEmpty(CurrentAvatarId)) return; + if (!ModuleConfig.Config.AvatarParameterActions.TryGetValue(CurrentAvatarId, out var actions)) return; + + actions.Remove(action); + if (actions.Count == 0) + ModuleConfig.Config.AvatarParameterActions.Remove(CurrentAvatarId); + + if (_selectedAction == action) + _selectedAction = null; + + ModuleConfig.SaveDeferred(); + InvokeAsync(StateHasChanged); + } + + private void OnActionChanged() + { + ModuleConfig.SaveDeferred(); + } + + private void SelectParameter(string paramName) + { + if (_selectedAction == null) return; + _selectedAction.ParameterName = paramName; + ModuleConfig.SaveDeferred(); + InvokeAsync(StateHasChanged); + } + + private IEnumerable> FilteredAvatarParams => + ShockOsc.AllAvatarParams + .Where(x => !x.Key.StartsWith("ShockOsc/")) + .Where(x => string.IsNullOrEmpty(_parameterSearch) || + x.Key.Contains(_parameterSearch, StringComparison.InvariantCultureIgnoreCase)) + .OrderBy(x => x.Key); + + private string GetGroupName(Guid groupId) + { + if (groupId == Guid.Empty) return "_All (All Shockers)"; + return ModuleConfig.Config.Groups.TryGetValue(groupId, out var group) ? group.Name : "Unknown"; + } + + private string GetActionSummary(AvatarParameterAction action) + { + var trigger = action.TriggerKind switch + { + ParameterTriggerKind.OnTrue => "On True", + ParameterTriggerKind.Threshold => $"Threshold >= {action.Threshold:F2}", + ParameterTriggerKind.OnChange => "On Change", + _ => "Unknown" + }; + return $"{action.Action} | {trigger} | {GetGroupName(action.GroupId)}"; + } + + private static readonly ControlType[] ActionTypes = [ControlType.Shock, ControlType.Vibrate, ControlType.Sound]; + private static readonly ParameterTriggerKind[] TriggerKinds = Enum.GetValues(); + + private bool _overrideIntensity; + private bool _overrideDuration; + + private void OnSelectAction(AvatarParameterAction? action) + { + _selectedAction = action; + if (action != null) + { + _overrideIntensity = action.OverrideIntensity.HasValue; + _overrideDuration = action.OverrideDuration.HasValue; + } + } + + private void OnOverrideIntensityChanged(bool value) + { + _overrideIntensity = value; + if (_selectedAction == null) return; + _selectedAction.OverrideIntensity = value ? (byte)50 : null; + ModuleConfig.SaveDeferred(); + } + + private void OnOverrideDurationChanged(bool value) + { + _overrideDuration = value; + if (_selectedAction == null) return; + _selectedAction.OverrideDuration = value ? (ushort)2000 : null; + ModuleConfig.SaveDeferred(); + } + + private byte IntensityValue + { + get => _selectedAction?.OverrideIntensity ?? 50; + set + { + if (_selectedAction == null) return; + _selectedAction.OverrideIntensity = value; + ModuleConfig.SaveDeferred(); + } + } + + private ushort DurationValue + { + get => _selectedAction?.OverrideDuration ?? 2000; + set + { + if (_selectedAction == null) return; + _selectedAction.OverrideDuration = value; + ModuleConfig.SaveDeferred(); + } + } +} + +@if (string.IsNullOrEmpty(CurrentAvatarId)) +{ + + No avatar detected. Connect to VRChat to configure avatar parameter actions. + +} +else +{ + + Avatar ID: @CurrentAvatarId + + + + Add Parameter Action + + + @if (CurrentActions.Count == 0) + { + + No parameter actions configured for this avatar. Click "Add Parameter Action" to + create one. + + + } + else + { + + Configured Actions + +
+ + + + Parameter + Configuration + + + + + @(string.IsNullOrEmpty(context.ParameterName) ? "(not set)" : context.ParameterName) + + @GetActionSummary(context) + + + + + +
+ } + + @if (_selectedAction != null) + { + + Action Settings + +
+ + Select Avatar Parameter + + +
+ @foreach (var param in FilteredAvatarParams) + { + var isSelected = _selectedAction.ParameterName == param.Key; + + @param.Key + @param.Value + + } +
+ + + + + @foreach (var actionType in ActionTypes) + { + @actionType + } + + + + @foreach (var trigger in TriggerKinds) + { + @trigger + } + + + @if (_selectedAction.TriggerKind == ParameterTriggerKind.Threshold) + { + + Threshold: @_selectedAction.Threshold.ToString("F2") + + } + + + _All (All Shockers) + @foreach (var group in ModuleConfig.Config.Groups) + { + @group.Value.Name + } + + + + @if (_overrideIntensity) + { + + Intensity: @IntensityValue% + + } + + + @if (_overrideDuration) + { + + Duration: @MathF.Round(DurationValue / 1000f, 1).ToString(System.Globalization.CultureInfo.InvariantCulture)s + + } +
+ } +} diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/ChatboxTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/ChatboxTab.razor index 4061a92..30ee738 100644 --- a/ShockOsc/Ui/Pages/Dash/Tabs/ChatboxTab.razor +++ b/ShockOsc/Ui/Pages/Dash/Tabs/ChatboxTab.razor @@ -59,7 +59,7 @@
- + @foreach (ControlType controlType in Enum.GetValues(typeof(ControlType))) { diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor index a4fa79b..f7647e8 100644 --- a/ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor +++ b/ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor @@ -10,6 +10,7 @@ @using ProgramGroup = OpenShock.ShockOSC.Models.ProgramGroup @page "/dash/groups" +@implements IDisposable @code { @@ -161,7 +162,7 @@ InvokeAsync(StateHasChanged); } - private void Dispose() + public void Dispose() { UnderscoreConfig.OnGroupConfigUpdate -= OnGroupConfigUpdate; } @@ -324,7 +325,7 @@
+ Label="Cooldown Time (ms)" @bind-Value:after="OnGroupSettingsValueChange"/> diff --git a/copy-module-dll.cmd b/copy-module-dll.cmd deleted file mode 100644 index e700ca7..0000000 --- a/copy-module-dll.cmd +++ /dev/null @@ -1 +0,0 @@ -copy ShockOsc\bin\Debug\net9.0\OpenShock.ShockOSC.dll %appdata%\OpenShock\Desktop\modules\openshock.shockosc\OpenShock.ShockOSC.dll \ No newline at end of file From 8a18a55c966231f8c4e050c4cb349d8075fede56 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Tue, 26 May 2026 22:42:08 +0200 Subject: [PATCH 2/9] add connection debug chip --- ShockOsc/Services/ShockOsc.cs | 9 +++++++++ ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor | 21 ++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/ShockOsc/Services/ShockOsc.cs b/ShockOsc/Services/ShockOsc.cs index 3a80159..eb57ce5 100644 --- a/ShockOsc/Services/ShockOsc.cs +++ b/ShockOsc/Services/ShockOsc.cs @@ -34,6 +34,9 @@ public sealed class ShockOsc private bool _oscServerActive; private bool _isAfk; + public bool IsGameConnected { get; private set; } + public bool IsConnectedViaOscQuery { get; private set; } + public event Action? OnGameConnectionChanged; public string AvatarId = string.Empty; private readonly Random Random = new(); @@ -120,6 +123,9 @@ private async Task SetupVrcClient((OscQueryServer, IPEndPoint)? client) { // stop tasks _oscServerActive = false; + IsGameConnected = false; + IsConnectedViaOscQuery = false; + OnGameConnectionChanged?.Invoke(); await Task.Delay(1000); // wait for tasks to stop TODO: REWORK THIS if (client != null) @@ -139,6 +145,9 @@ private async Task SetupVrcClient((OscQueryServer, IPEndPoint)? client) // Start tasks _oscServerActive = true; + IsGameConnected = true; + IsConnectedViaOscQuery = client != null; + OnGameConnectionChanged?.Invoke(); OsTask.Run(ReceiverLoopAsync); OsTask.Run(SenderLoopAsync); OsTask.Run(CheckLoop); diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor index d0aa970..0b46806 100644 --- a/ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor +++ b/ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor @@ -7,7 +7,22 @@ @page "/dash/debug" - Avatar ID: @ShockOsc.AvatarId +
+ + @if (ShockOsc.IsGameConnected) + { + @($"Connected{(ShockOsc.IsConnectedViaOscQuery ? " (OSCQuery)" : " (Manual)")}") + } + else + { + @("Not Connected") + } + + Avatar ID: @ShockOsc.AvatarId +
OSC Parameters @@ -72,10 +87,13 @@ protected override void OnInitialized() { _onParamsVhangeSubscription = ShockOsc.OnParamsChangeObservable.Subscribe(OnParamsChange); + ShockOsc.OnGameConnectionChanged += OnConnectionChanged; OsTask.Run(UpdateParams); } + private void OnConnectionChanged() => _updateQueued = true; + private async Task UpdateParams() { while (!_cts.IsCancellationRequested) @@ -96,6 +114,7 @@ public async ValueTask DisposeAsync() { + ShockOsc.OnGameConnectionChanged -= OnConnectionChanged; _onParamsVhangeSubscription?.Dispose(); await _cts.CancelAsync(); } From 3da2f10d4e2026f4680bdd2a3e23e96aeb20e421 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Tue, 26 May 2026 22:45:16 +0200 Subject: [PATCH 3/9] handle cancellation of tasks better --- ShockOsc/Services/ShockOsc.cs | 63 ++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/ShockOsc/Services/ShockOsc.cs b/ShockOsc/Services/ShockOsc.cs index eb57ce5..ecb8639 100644 --- a/ShockOsc/Services/ShockOsc.cs +++ b/ShockOsc/Services/ShockOsc.cs @@ -32,7 +32,8 @@ public sealed class ShockOsc private readonly OscHandler _oscHandler; private readonly ChatboxService _chatboxService; - private bool _oscServerActive; + private CancellationTokenSource _loopCts = new(); + private Task[] _loopTasks = []; private bool _isAfk; public bool IsGameConnected { get; private set; } public bool IsConnectedViaOscQuery { get; private set; } @@ -121,12 +122,15 @@ private void SetupGroups() private async Task SetupVrcClient((OscQueryServer, IPEndPoint)? client) { - // stop tasks - _oscServerActive = false; + // Stop existing loops + await _loopCts.CancelAsync(); + await Task.WhenAll(_loopTasks); + _loopCts.Dispose(); + _loopCts = new CancellationTokenSource(); + IsGameConnected = false; IsConnectedViaOscQuery = false; OnGameConnectionChanged?.Invoke(); - await Task.Delay(1000); // wait for tasks to stop TODO: REWORK THIS if (client != null) { @@ -144,13 +148,17 @@ private async Task SetupVrcClient((OscQueryServer, IPEndPoint)? client) _logger.LogInformation("Connecting UDP Clients..."); // Start tasks - _oscServerActive = true; + var ct = _loopCts.Token; + _loopTasks = + [ + Task.Run(() => ReceiverLoopAsync(ct), ct), + Task.Run(() => SenderLoopAsync(ct), ct), + Task.Run(() => CheckLoop(ct), ct) + ]; + IsGameConnected = true; IsConnectedViaOscQuery = client != null; OnGameConnectionChanged?.Invoke(); - OsTask.Run(ReceiverLoopAsync); - OsTask.Run(SenderLoopAsync); - OsTask.Run(CheckLoop); _logger.LogInformation("Ready"); OsTask.Run(_underscoreConfig.SendUpdateForAll); @@ -261,35 +269,30 @@ private void CheckCustomParameterAction(string paramName, object? oldValue, obje } } - private async Task ReceiverLoopAsync() + private async Task ReceiverLoopAsync(CancellationToken ct) { - while (_oscServerActive) + while (!ct.IsCancellationRequested) { try { - await ReceiveLogic(); + var receiveTask = _oscClient.ReceiveGameMessage(); + if (receiveTask == null) break; + await receiveTask.WaitAsync(ct); + await ReceiveLogic(receiveTask.Result); + } + catch (OperationCanceledException) + { + break; } catch (Exception e) { _logger.LogError(e, "Error in receiver loop"); } } - // ReSharper disable once FunctionNeverReturns } - private async Task ReceiveLogic() + private async Task ReceiveLogic(OscMessage received) { - OscMessage received; - try - { - received = await _oscClient.ReceiveGameMessage()!; - } - catch (Exception e) - { - _logger.LogTrace(e, "Error receiving message"); - return; - } - var addr = received.Address; if (addr.StartsWith("/avatar/parameters/")) @@ -576,12 +579,12 @@ private ValueTask LogIgnoredAfk() return _chatboxService.SendGenericMessage(_moduleConfig.Config.Chatbox.IgnoredAfk); } - private async Task SenderLoopAsync() + private async Task SenderLoopAsync(CancellationToken ct) { - while (_oscServerActive) + while (!ct.IsCancellationRequested) { await _oscHandler.SendParams(); - await Task.Delay(300); + await Task.Delay(300, ct); } } @@ -648,9 +651,9 @@ private async Task SendCommand(ProgramGroup programGroup, ushort duration, byte await _chatboxService.SendLocalControlMessage(programGroup.Name, actualIntensity, actualDuration, type); } - private async Task CheckLoop() + private async Task CheckLoop(CancellationToken ct) { - while (_oscServerActive) + while (!ct.IsCancellationRequested) { try { @@ -661,7 +664,7 @@ private async Task CheckLoop() _logger.LogError(e, "Error in check loop"); } - await Task.Delay(20); + await Task.Delay(20, ct); } } From f5e67f60551dda986ef09d98e1a72f226f57599c Mon Sep 17 00:00:00 2001 From: LucHeart Date: Tue, 26 May 2026 22:48:36 +0200 Subject: [PATCH 4/9] use periodic timer --- ShockOsc/Services/ShockOsc.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ShockOsc/Services/ShockOsc.cs b/ShockOsc/Services/ShockOsc.cs index ecb8639..1cc49e0 100644 --- a/ShockOsc/Services/ShockOsc.cs +++ b/ShockOsc/Services/ShockOsc.cs @@ -581,10 +581,10 @@ private ValueTask LogIgnoredAfk() private async Task SenderLoopAsync(CancellationToken ct) { - while (!ct.IsCancellationRequested) + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(300)); + while (await timer.WaitForNextTickAsync(ct)) { await _oscHandler.SendParams(); - await Task.Delay(300, ct); } } @@ -653,7 +653,8 @@ private async Task SendCommand(ProgramGroup programGroup, ushort duration, byte private async Task CheckLoop(CancellationToken ct) { - while (!ct.IsCancellationRequested) + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(50)); + while (await timer.WaitForNextTickAsync(ct)) { try { @@ -663,8 +664,6 @@ private async Task CheckLoop(CancellationToken ct) { _logger.LogError(e, "Error in check loop"); } - - await Task.Delay(20, ct); } } From 658ab080ce856dee4c6243d04248004c128d4839 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Tue, 26 May 2026 23:00:34 +0200 Subject: [PATCH 5/9] move check loop behind advanced tab, change duration inputs to sliders --- ShockOsc/Config/BehaviourConf.cs | 1 + ShockOsc/Services/ShockOsc.cs | 3 +- ShockOsc/Ui/Pages/Dash/Tabs/ConfigTab.razor | 50 ++++++++++++++++++--- ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor | 14 +++++- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/ShockOsc/Config/BehaviourConf.cs b/ShockOsc/Config/BehaviourConf.cs index a3f95d7..8a617cf 100644 --- a/ShockOsc/Config/BehaviourConf.cs +++ b/ShockOsc/Config/BehaviourConf.cs @@ -5,4 +5,5 @@ public sealed class BehaviourConf : SharedBehaviourConfig public uint HoldTime { get; set; } = 250; public bool DisableWhileAfk { get; set; } = true; public bool ForceUnmute { get; set; } + public uint CheckLoopIntervalMs { get; set; } = 50; } \ No newline at end of file diff --git a/ShockOsc/Services/ShockOsc.cs b/ShockOsc/Services/ShockOsc.cs index 1cc49e0..447bc5b 100644 --- a/ShockOsc/Services/ShockOsc.cs +++ b/ShockOsc/Services/ShockOsc.cs @@ -653,7 +653,8 @@ private async Task SendCommand(ProgramGroup programGroup, ushort duration, byte private async Task CheckLoop(CancellationToken ct) { - using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(50)); + var intervalMs = Math.Max(10, _moduleConfig.Config.Behaviour.CheckLoopIntervalMs); + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(intervalMs)); while (await timer.WaitForNextTickAsync(ct)) { try diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/ConfigTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/ConfigTab.razor index a9f5004..731d04b 100644 --- a/ShockOsc/Ui/Pages/Dash/Tabs/ConfigTab.razor +++ b/ShockOsc/Ui/Pages/Dash/Tabs/ConfigTab.razor @@ -34,12 +34,30 @@
-
- - +
+ + Cooldown: @MathF.Round(ModuleConfig.Config.Behaviour.CooldownTime / 1000f, 1).ToString(CultureInfo.InvariantCulture)s + + +
+ +
+ + Hold Time: @ModuleConfig.Config.Behaviour.HoldTime ms + +
@@ -181,6 +199,26 @@ } + + + +
+ + Check Loop Interval: @ModuleConfig.Config.Behaviour.CheckLoopIntervalMs ms + + +
+
+
+
+ @code { [ModuleInject] private UnderscoreConfig UnderscoreConfig { get; set; } = null!; diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor index f7647e8..ef251bc 100644 --- a/ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor +++ b/ShockOsc/Ui/Pages/Dash/Tabs/GroupsTab.razor @@ -324,8 +324,18 @@
- +
+ + Cooldown: @MathF.Round(CurrentGroup.CooldownTime / 1000f, 1).ToString(CultureInfo.InvariantCulture)s + + +
From 0ecfb755eee60932b80c167aaa23b1db647df404 Mon Sep 17 00:00:00 2001 From: LucHeart Date: Tue, 26 May 2026 23:04:11 +0200 Subject: [PATCH 6/9] add false trigger, show live info about the custom actions --- ShockOsc/Config/AvatarParameterAction.cs | 5 ++ ShockOsc/Services/ShockOsc.cs | 3 + .../Ui/Pages/Dash/Tabs/AvatarActionsTab.razor | 63 +++++++++++++++++++ ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor | 11 ++-- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/ShockOsc/Config/AvatarParameterAction.cs b/ShockOsc/Config/AvatarParameterAction.cs index ae9c8b3..26a565a 100644 --- a/ShockOsc/Config/AvatarParameterAction.cs +++ b/ShockOsc/Config/AvatarParameterAction.cs @@ -20,6 +20,11 @@ public enum ParameterTriggerKind /// OnTrue, + /// + /// Triggers when a bool parameter becomes false + /// + OnFalse, + /// /// Triggers when a float parameter exceeds the threshold /// diff --git a/ShockOsc/Services/ShockOsc.cs b/ShockOsc/Services/ShockOsc.cs index 447bc5b..c157fad 100644 --- a/ShockOsc/Services/ShockOsc.cs +++ b/ShockOsc/Services/ShockOsc.cs @@ -38,6 +38,7 @@ public sealed class ShockOsc public bool IsGameConnected { get; private set; } public bool IsConnectedViaOscQuery { get; private set; } public event Action? OnGameConnectionChanged; + public event Action? OnAvatarActionTriggered; public string AvatarId = string.Empty; private readonly Random Random = new(); @@ -236,6 +237,7 @@ private void CheckCustomParameterAction(string paramName, object? oldValue, obje var shouldTrigger = action.TriggerKind switch { ParameterTriggerKind.OnTrue => newValue is true && oldValue is not true, + ParameterTriggerKind.OnFalse => newValue is false && oldValue is not false, ParameterTriggerKind.Threshold => newValue is float f && f >= action.Threshold && (oldValue is not float oldF || oldF < action.Threshold), ParameterTriggerKind.OnChange => !Equals(newValue, oldValue) && newValue is not null && @@ -265,6 +267,7 @@ private void CheckCustomParameterAction(string paramName, object? oldValue, obje "Custom parameter action triggered: {Param} -> {Action} on group {Group} (intensity: {Intensity}, duration: {Duration}ms)", paramName, action.Action, programGroup.Name, intensity, duration); + OnAvatarActionTriggered?.Invoke(action); OsTask.Run(() => SendCommand(programGroup, duration, intensity, action.Action)); } } diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor index 9744175..d31db6c 100644 --- a/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor +++ b/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor @@ -5,8 +5,10 @@ @using OpenShock.ShockOSC.Config @using OpenShock.ShockOSC.Services @using OpenShock.ShockOSC.Ui.Utils +@using OpenShock.ShockOSC.Utils @page "/dash/avatar-actions" +@implements IAsyncDisposable @code { @@ -30,6 +32,54 @@ private AvatarParameterAction? _selectedAction; private string _parameterSearch = ""; + private readonly Dictionary _lastTriggered = new(); + private IDisposable? _paramsSubscription; + private readonly CancellationTokenSource _cts = new(); + private bool _updateQueued; + + protected override void OnInitialized() + { + _paramsSubscription = ShockOsc.OnParamsChangeObservable.Subscribe(_ => _updateQueued = true); + ShockOsc.OnAvatarActionTriggered += OnActionTriggered; + OsTask.Run(UpdateLoop); + } + + private async Task UpdateLoop() + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200)); + while (await timer.WaitForNextTickAsync(_cts.Token)) + { + if (!_updateQueued) continue; + _updateQueued = false; + await InvokeAsync(StateHasChanged); + } + } + + private void OnActionTriggered(AvatarParameterAction action) + { + _lastTriggered[action] = DateTime.UtcNow; + _updateQueued = true; + } + + private bool IsRecentlyTriggered(AvatarParameterAction action) + { + return _lastTriggered.TryGetValue(action, out var time) && + (DateTime.UtcNow - time).TotalSeconds < 2; + } + + private string GetCurrentValue(string paramName) + { + if (string.IsNullOrEmpty(paramName)) return ""; + return ShockOsc.AllAvatarParams.TryGetValue(paramName, out var val) ? $"{val}" : ""; + } + + public async ValueTask DisposeAsync() + { + ShockOsc.OnAvatarActionTriggered -= OnActionTriggered; + _paramsSubscription?.Dispose(); + await _cts.CancelAsync(); + } + private void AddAction() { if (string.IsNullOrEmpty(CurrentAvatarId)) return; @@ -62,6 +112,7 @@ if (_selectedAction == action) _selectedAction = null; + _lastTriggered.Remove(action); ModuleConfig.SaveDeferred(); InvokeAsync(StateHasChanged); } @@ -97,6 +148,7 @@ var trigger = action.TriggerKind switch { ParameterTriggerKind.OnTrue => "On True", + ParameterTriggerKind.OnFalse => "On False", ParameterTriggerKind.Threshold => $"Threshold >= {action.Threshold:F2}", ParameterTriggerKind.OnChange => "On Change", _ => "Unknown" @@ -194,14 +246,25 @@ else + Parameter + Value Configuration + + @if (IsRecentlyTriggered(context)) + { + + } + @(string.IsNullOrEmpty(context.ParameterName) ? "(not set)" : context.ParameterName) + + @GetCurrentValue(context.ParameterName) + @GetActionSummary(context) Date: Tue, 26 May 2026 23:37:51 +0200 Subject: [PATCH 7/9] add live control into the mix --- ShockOsc/Config/AvatarParameterAction.cs | 3 + ShockOsc/Services/ShockOsc.cs | 40 ++++++++-- .../Ui/Pages/Dash/Tabs/AvatarActionsTab.razor | 74 ++++++++++++------- 3 files changed, 86 insertions(+), 31 deletions(-) diff --git a/ShockOsc/Config/AvatarParameterAction.cs b/ShockOsc/Config/AvatarParameterAction.cs index 26a565a..2c364dc 100644 --- a/ShockOsc/Config/AvatarParameterAction.cs +++ b/ShockOsc/Config/AvatarParameterAction.cs @@ -11,6 +11,9 @@ public sealed class AvatarParameterAction public float Threshold { get; set; } = 0.5f; public byte? OverrideIntensity { get; set; } public ushort? OverrideDuration { get; set; } + public bool IsLiveControl { get; set; } + public float LiveControlMin { get; set; } + public float LiveControlMax { get; set; } = 1f; } public enum ParameterTriggerKind diff --git a/ShockOsc/Services/ShockOsc.cs b/ShockOsc/Services/ShockOsc.cs index c157fad..d6a53aa 100644 --- a/ShockOsc/Services/ShockOsc.cs +++ b/ShockOsc/Services/ShockOsc.cs @@ -234,6 +234,40 @@ private void CheckCustomParameterAction(string paramName, object? oldValue, obje { if (action.ParameterName != paramName) continue; + if (!_dataLayer.ProgramGroups.TryGetValue(action.GroupId, out var programGroup)) + { + _logger.LogWarning("Custom parameter action references unknown group {GroupId}", action.GroupId); + continue; + } + + if (action.IsLiveControl) + { + var rawValue = newValue switch + { + float f => f, + int i => i, + true => action.LiveControlMax, + _ => action.LiveControlMin + }; + + var range = action.LiveControlMax - action.LiveControlMin; + var liveIntensity = range == 0f + ? 0f + : MathUtils.Saturate((rawValue - action.LiveControlMin) / range); + + var scaledIntensity = Convert.ToByte(liveIntensity * 100f); + if (action.OverrideIntensity.HasValue && scaledIntensity > 0) + scaledIntensity = GetScaledIntensity(programGroup, scaledIntensity); + + programGroup.ConcurrentIntensity = scaledIntensity; + programGroup.ConcurrentType = scaledIntensity > 0 ? action.Action : ControlType.Stop; + + if (scaledIntensity > 0) + OnAvatarActionTriggered?.Invoke(action); + + continue; + } + var shouldTrigger = action.TriggerKind switch { ParameterTriggerKind.OnTrue => newValue is true && oldValue is not true, @@ -247,12 +281,6 @@ private void CheckCustomParameterAction(string paramName, object? oldValue, obje if (!shouldTrigger) continue; - if (!_dataLayer.ProgramGroups.TryGetValue(action.GroupId, out var programGroup)) - { - _logger.LogWarning("Custom parameter action references unknown group {GroupId}", action.GroupId); - continue; - } - if (!CheckAndSetAllPreconditions(programGroup).IsT0) { _logger.LogDebug("Custom parameter action skipped due to preconditions for group {Group}", diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor index d31db6c..6617936 100644 --- a/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor +++ b/ShockOsc/Ui/Pages/Dash/Tabs/AvatarActionsTab.razor @@ -145,6 +145,9 @@ private string GetActionSummary(AvatarParameterAction action) { + if (action.IsLiveControl) + return $"{action.Action} | Live Control | {GetGroupName(action.GroupId)}"; + var trigger = action.TriggerKind switch { ParameterTriggerKind.OnTrue => "On True", @@ -311,22 +314,40 @@ else } - - @foreach (var trigger in TriggerKinds) - { - @trigger - } - + + + @if (_selectedAction.IsLiveControl) + { +
+ + +
+ } - @if (_selectedAction.TriggerKind == ParameterTriggerKind.Threshold) + @if (!_selectedAction.IsLiveControl) { - - Threshold: @_selectedAction.Threshold.ToString("F2") - + + @foreach (var trigger in TriggerKinds) + { + @trigger + } + + + @if (_selectedAction.TriggerKind == ParameterTriggerKind.Threshold) + { + + Threshold: @_selectedAction.Threshold.ToString("F2") + + } } - - @if (_overrideIntensity) + @if (!_selectedAction.IsLiveControl) { - - Intensity: @IntensityValue% - - } + + @if (_overrideIntensity) + { + + Intensity: @IntensityValue% + + } - + + } @if (_overrideDuration) { Date: Tue, 26 May 2026 23:44:55 +0200 Subject: [PATCH 8/9] bump version 3.2.0-preview.1 --- ShockOsc/ShockOsc.csproj | 4 ++-- ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ShockOsc/ShockOsc.csproj b/ShockOsc/ShockOsc.csproj index 0b02ef7..83eff9b 100644 --- a/ShockOsc/ShockOsc.csproj +++ b/ShockOsc/ShockOsc.csproj @@ -10,8 +10,8 @@ OpenShock.ShockOSC OpenShock - 3.1.0 - 3.1.0 + 3.2.0 + 3.2.0-preview.1 openshock.shockosc ShockOsc diff --git a/ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor b/ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor index e1106af..ef458d4 100644 --- a/ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor +++ b/ShockOsc/Ui/Pages/Dash/Tabs/DebugTab.razor @@ -1,4 +1,4 @@ -i@using OpenShock.Desktop.ModuleBase +@using OpenShock.Desktop.ModuleBase @using OpenShock.Desktop.ModuleBase.Api @using OpenShock.ShockOSC.Services @using OpenShock.ShockOSC.Utils From b083a1340b0a62a811226f6316efff45c2f7746a Mon Sep 17 00:00:00 2001 From: LucHeart Date: Tue, 26 May 2026 23:46:04 +0200 Subject: [PATCH 9/9] fix potentioal cancellation exception --- ShockOsc/Services/ShockOsc.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ShockOsc/Services/ShockOsc.cs b/ShockOsc/Services/ShockOsc.cs index d6a53aa..566f204 100644 --- a/ShockOsc/Services/ShockOsc.cs +++ b/ShockOsc/Services/ShockOsc.cs @@ -125,7 +125,8 @@ private async Task SetupVrcClient((OscQueryServer, IPEndPoint)? client) { // Stop existing loops await _loopCts.CancelAsync(); - await Task.WhenAll(_loopTasks); + try { await Task.WhenAll(_loopTasks); } + catch (OperationCanceledException) { } _loopCts.Dispose(); _loopCts = new CancellationTokenSource();