From b02d49278913950ab28eafae1acc34fd3858d91e Mon Sep 17 00:00:00 2001 From: Ricky Merc <65817116+RickyB505@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:52:19 -0400 Subject: [PATCH 1/3] fix: optimization and fix vehicle spawning so its not scuffed for some --- .../Vehicles/VehicleSpawning.cs | 7 ++++-- .../vMenu.Enhanced.Menus/World/WorldState.cs | 24 ++++++++++++++++--- .../vMenu.Enhanced.Menus/World/WorldTime.cs | 2 +- .../World/WorldWeather.cs | 4 ++-- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleSpawning.cs b/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleSpawning.cs index 308556bf..2bb53a2a 100644 --- a/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleSpawning.cs +++ b/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleSpawning.cs @@ -47,7 +47,7 @@ public static class VehicleSpawning var position = ped.Position; Vector3? velocity = null; - var rpm = 100f; + var rpm = 0f; var speed = 0f; if (ped.IsPedInAnyVehicle()) @@ -100,7 +100,10 @@ public static class VehicleSpawning newVehicle.Velocity = velocity.Value; } - Native.SetVehicleCurrentRpm(newVehicle.Handle, rpm); + if (rpm > 0.2f) + { + Native.SetVehicleCurrentRpm(newVehicle.Handle, rpm); + } ped.SetPedIntoVehicle(newVehicle.Handle, -1); diff --git a/src/Client/vMenu.Enhanced.Menus/World/WorldState.cs b/src/Client/vMenu.Enhanced.Menus/World/WorldState.cs index 0c2e03c4..5dad46c7 100644 --- a/src/Client/vMenu.Enhanced.Menus/World/WorldState.cs +++ b/src/Client/vMenu.Enhanced.Menus/World/WorldState.cs @@ -31,7 +31,10 @@ public static class WorldState private const double HardResyncSeconds = 30.0; private const double DriftCorrection = 0.1; - + private static float _speedMultiplier = ClientConfig.Value(TimeOptionsSettings.SpeedMultiplier); + public static int TimeTransitionSeconds = ClientConfig.Value(TimeOptionsSettings.TransitionSeconds); + public static int WeatherTransitionSeconds = ClientConfig.Value(WeatherOptionsSettings.TransitionSeconds); + public static bool SyncClouds = ClientConfig.Value(WeatherOptionsSettings.SyncClouds); private static double _anchorUnix; private static int _anchorTimerMs; private static bool _anchored; @@ -57,7 +60,7 @@ public static class WorldState // Read live rather than cached, so raising it takes effect without a restart. The same convar // reaches every client, so nobody's sky runs at a different speed from anybody else's. public static double TimeSpeed => - GameClock.ClampSpeed(ClientConfig.Value(TimeOptionsSettings.SpeedMultiplier)); + GameClock.ClampSpeed(_speedMultiplier); /// The clock with the server's offset applied, as an in-game second of day. public static double SecondOfDay => @@ -85,7 +88,22 @@ public static void Initialize() TickRate.Every(PollIntervalMs), IsNeeded, onStarted: Poll); - + BrokenNatives.NativeFixer.AddConvarChangeListener(TimeOptionsSettings.SpeedMultiplier.Name, (string convar, object? newValue) => + { + _speedMultiplier = ClientConfig.GetFloat(convar) ?? _speedMultiplier; + }); + BrokenNatives.NativeFixer.AddConvarChangeListener(TimeOptionsSettings.TransitionSeconds.Name, (string convar, object? newValue) => + { + TimeTransitionSeconds = ClientConfig.GetInt(convar) ?? TimeTransitionSeconds; + }); + BrokenNatives.NativeFixer.AddConvarChangeListener(WeatherOptionsSettings.TransitionSeconds.Name, (string convar, object? newValue) => + { + WeatherTransitionSeconds = ClientConfig.GetInt(convar) ?? WeatherTransitionSeconds; + }); + BrokenNatives.NativeFixer.AddConvarChangeListener(WeatherOptionsSettings.SyncClouds.Name, (string convar, object? newValue) => + { + SyncClouds = ClientConfig.GetBool(convar) ?? SyncClouds; + }); SharedAPI.Commands.RegisterCommand(DumpCommand, false, DebugCommands.Gate(Dump)); } diff --git a/src/Client/vMenu.Enhanced.Menus/World/WorldTime.cs b/src/Client/vMenu.Enhanced.Menus/World/WorldTime.cs index 1854b5e3..93309fbe 100644 --- a/src/Client/vMenu.Enhanced.Menus/World/WorldTime.cs +++ b/src/Client/vMenu.Enhanced.Menus/World/WorldTime.cs @@ -157,7 +157,7 @@ private static double Ramp() _ramping = true; } - var seconds = Math.Max(0, ClientConfig.Value(TimeOptionsSettings.TransitionSeconds)); + var seconds = Math.Max(0, WorldState.TimeTransitionSeconds); if (seconds <= 0) { diff --git a/src/Client/vMenu.Enhanced.Menus/World/WorldWeather.cs b/src/Client/vMenu.Enhanced.Menus/World/WorldWeather.cs index 25bfc31d..87673793 100644 --- a/src/Client/vMenu.Enhanced.Menus/World/WorldWeather.cs +++ b/src/Client/vMenu.Enhanced.Menus/World/WorldWeather.cs @@ -123,7 +123,7 @@ private static void Apply() _wasForced = forced is not null; // A joining player gets the sky it should already be under, so no fade on the first pass. - if (ClientConfig.Value(WeatherOptionsSettings.SyncClouds)) + if (WorldState.SyncClouds) { WorldClouds.Apply(CloudTarget(forced, schedule), first ? 0.0f : TransitionSeconds()); } @@ -163,7 +163,7 @@ private static void Set(WeatherType from, WeatherType to, double percent) => Native.SetWeatherTypeTransition(Hashes[(int)from], Hashes[(int)to], (float)percent); private static float TransitionSeconds() => - Math.Max(0, ClientConfig.Value(WeatherOptionsSettings.TransitionSeconds)); + Math.Max(0, WorldState.WeatherTransitionSeconds); // Swaps at the moment the sky starts moving rather than when the schedule flips, so the clouds // and the weather arrive together instead of the clouds lagging a boundary window behind. From 2d3a4a5fdeeca3ec09da61d12cac869a8f4d3369 Mon Sep 17 00:00:00 2001 From: Tom Grobbe Date: Sat, 15 Aug 2026 14:10:12 +0200 Subject: [PATCH 2/3] feat(config): listen for specific convars instead of every change --- .../ClientConfig.cs | 58 ++++-- src/Client/vMenu.Enhanced.Core/Main.cs | 1 - .../HeaderStyle.cs | 8 +- .../MenuRegistry.cs | 28 ++- .../Developer/DeveloperOverlay.cs | 3 + .../vMenu.Enhanced.Menus/Players/PvpMode.cs | 2 +- .../Vehicles/VehicleCommands.cs | 9 +- .../Vehicles/VehicleDumpCommands.cs | 2 +- .../vMenu.Enhanced.Menus/World/WorldState.cs | 150 ++++++++-------- .../vMenu.Enhanced.Menus/World/WorldTime.cs | 4 +- .../World/WorldWeather.cs | 4 +- .../ServerClock.cs | 8 +- .../ServerConfig.cs | 26 ++- .../vMenu.Enhanced.Core.Server/CoreServer.cs | 2 - .../Configuration/ConfigStore.cs | 165 +++++++++++++++++- .../World/WorldStateConvars.cs | 3 + 16 files changed, 352 insertions(+), 121 deletions(-) diff --git a/src/Client/vMenu.Enhanced.Configuration/ClientConfig.cs b/src/Client/vMenu.Enhanced.Configuration/ClientConfig.cs index 1620faa5..4602d4a9 100644 --- a/src/Client/vMenu.Enhanced.Configuration/ClientConfig.cs +++ b/src/Client/vMenu.Enhanced.Configuration/ClientConfig.cs @@ -18,12 +18,6 @@ public static class ClientConfig private static readonly ConfigStore Store = new(Native.GetConvar, ForwardLog); - public static event Action? Changed - { - add => Store.Changed += value; - remove => Store.Changed -= value; - } - /// Call once, before the menus are built, so the first gate pass reads real values. public static void Initialize() { @@ -31,27 +25,49 @@ public static void Initialize() ApplyLogLevel(); - // Any convar moving re-reads the level, because Changed does not say which one moved. - Store.Changed += ApplyLogLevel; + Store.Watch([Debugging.LogLevel], ApplyLogLevel); - // One listener per convar rather than a wildcard filter, which if it matched nothing would - // look like the module quietly not working. - foreach (var convar in Store.Tracked) - { - NativeFixer.AddConvarChangeListener(convar, OnConvarChanged); - } + Listen(Store.Tracked); SharedAPI.Commands.RegisterCommand(DumpCommand, false, DebugCommands.Gate(Dump)); } + /// Starts watching convars that are not settings, so listeners can be added for them. + /// See for why these are not in the catalog. + public static void Track(IReadOnlyList convars) => Listen(Store.Track(convars)); + + /// Calls whenever any of these settings changes, and nothing else. + public static void AddEventListenerFor(IReadOnlyList settings, Action handler) => + Store.Watch(settings, handler); + + /// The same, for convars registered through rather than catalogued settings. + public static void AddEventListenerFor(IReadOnlyList convars, Action handler) => + Store.Watch(convars, handler); + + /// + /// Calls whenever any setting other than these changes. For a + /// subscriber that really does react to almost anything, where naming the settings it reads + /// would mean one added later silently never reaching it. + /// + public static void AddEventListenerExcept(IReadOnlyList settings, Action handler) => + Store.WatchExcept(settings, handler); + + public static void RemoveEventListenerFor(IReadOnlyList settings, Action handler) => + Store.Unwatch(settings, handler); + + public static void RemoveEventListenerFor(IReadOnlyList convars, Action handler) => + Store.Unwatch(convars, handler); + + public static void RemoveEventListenerExcept(Action handler) => Store.UnwatchExcept(handler); + /// Prints what this client currently reads for every setting. public static void Dump() { - Log.Debug("[Config] Current values:"); + Log.Info("[Config] Current values:"); foreach (var line in Store.Describe()) { - Log.Debug("[Config] " + line); + Log.Info("[Config] " + line); } } @@ -79,6 +95,16 @@ public static void Dump() public static string Value(StringSetting setting) => Store.Value(setting); + // One listener per convar rather than a wildcard filter, which if it matched nothing would look + // like the module quietly not working. + private static void Listen(IReadOnlyList convars) + { + foreach (var convar in convars) + { + NativeFixer.AddConvarChangeListener(convar, OnConvarChanged); + } + } + private static void OnConvarChanged(string convar, object? reserved) => Store.NotifyChanged(convar); private static void ApplyLogLevel() => Log.SetLevel(Store.Value(Debugging.LogLevel)); diff --git a/src/Client/vMenu.Enhanced.Core/Main.cs b/src/Client/vMenu.Enhanced.Core/Main.cs index 40a0df95..652d8cff 100644 --- a/src/Client/vMenu.Enhanced.Core/Main.cs +++ b/src/Client/vMenu.Enhanced.Core/Main.cs @@ -82,7 +82,6 @@ public async void Initialize() LanguageLoader.Load(); - ClientConfig.Changed += TickRegistry.Reevaluate; ClientPermissions.PermissionsChanged += TickRegistry.Reevaluate; VehicleCommands.Initialize(); diff --git a/src/Client/vMenu.Enhanced.MenuFramework/HeaderStyle.cs b/src/Client/vMenu.Enhanced.MenuFramework/HeaderStyle.cs index 95b59c5b..aad4a575 100644 --- a/src/Client/vMenu.Enhanced.MenuFramework/HeaderStyle.cs +++ b/src/Client/vMenu.Enhanced.MenuFramework/HeaderStyle.cs @@ -26,7 +26,13 @@ private static readonly (string Name, int Id)[] Fonts = /// Call after , before the menus are built. public static void Initialize() { - ClientConfig.Changed += Apply; + ClientConfig.AddEventListenerFor( + [ + AppearanceSettings.TitleAlignment, + AppearanceSettings.TitleFont, + AppearanceSettings.HeaderGlare, + ], + Apply); Apply(); } diff --git a/src/Client/vMenu.Enhanced.MenuFramework/MenuRegistry.cs b/src/Client/vMenu.Enhanced.MenuFramework/MenuRegistry.cs index 95f95ee5..3c6e00b1 100644 --- a/src/Client/vMenu.Enhanced.MenuFramework/MenuRegistry.cs +++ b/src/Client/vMenu.Enhanced.MenuFramework/MenuRegistry.cs @@ -1,10 +1,15 @@ using MenuAPI; using vMenu.Enhanced.Configuration; +using vMenu.Enhanced.Data.Configuration; using vMenu.Enhanced.Logging; using vMenu.Enhanced.MenuFramework.Localization; using vMenu.Enhanced.Permissions; +using DebuggingSettings = vMenu.Enhanced.Data.Configuration.Settings.Debugging; +using KeyBindingSettings = vMenu.Enhanced.Data.Configuration.Settings.KeyBindings; +using LocalizationSettings = vMenu.Enhanced.Data.Configuration.Settings.Localization; + namespace vMenu.Enhanced.MenuFramework; /// Builds the menu tree and keeps it in step with permissions, configuration and language. @@ -12,6 +17,25 @@ namespace vMenu.Enhanced.MenuFramework; // menu, for one place to unsubscribe and a deterministic order. public static class MenuRegistry { + /// + /// The settings that provably cannot change what any menu shows, so a refresh pass over every + /// gate and every label is not worth running for them. + /// + private static readonly Setting[] Ignored = + [ + DebuggingSettings.LogLevel, + + // Read once by LanguageLoader before the menus are built. + // Can't be updated at runtime because the translation files would not be + // streamed to the client if changed without a resource restart. + LocalizationSettings.Languages, + + // Keybinds get registered with the game at startup, so changing one takes a restart either way. + KeyBindingSettings.MenuToggleKey, + KeyBindingSettings.NoClipToggleKey, + KeyBindingSettings.TeleportKey, + ]; + private static readonly List Hosts = []; private static readonly Dictionary HostsByMenu = new(ReferenceComparer.Instance); @@ -64,7 +88,7 @@ public static async Task BuildAsync(IReadOnlyList definitions) await MaterialiseAsync(_root, localizer); ClientPermissions.PermissionsChanged += RefreshAll; - ClientConfig.Changed += RefreshAll; + ClientConfig.AddEventListenerExcept(Ignored, RefreshAll); Localizer.Changed += RefreshAll; // Items are created enabled, so without this everything looks unlocked until the first @@ -104,7 +128,7 @@ public static void Dispose() } ClientPermissions.PermissionsChanged -= RefreshAll; - ClientConfig.Changed -= RefreshAll; + ClientConfig.RemoveEventListenerExcept(RefreshAll); Localizer.Changed -= RefreshAll; foreach (var host in Hosts) diff --git a/src/Client/vMenu.Enhanced.Menus/Developer/DeveloperOverlay.cs b/src/Client/vMenu.Enhanced.Menus/Developer/DeveloperOverlay.cs index 14711699..a97f95b3 100644 --- a/src/Client/vMenu.Enhanced.Menus/Developer/DeveloperOverlay.cs +++ b/src/Client/vMenu.Enhanced.Menus/Developer/DeveloperOverlay.cs @@ -4,6 +4,7 @@ using CitizenFX.FiveM.Client; using vMenu.Enhanced.BrokenNatives; +using vMenu.Enhanced.Configuration; using vMenu.Enhanced.Data.Ticks; using vMenu.Enhanced.MenuFramework; using vMenu.Enhanced.Ticks; @@ -85,6 +86,8 @@ public static void Initialize() Condition.Evaluate, onStopped: Reset); + ClientConfig.AddEventListenerFor([DeveloperFeaturesSetting.Enabled], Reevaluate); + DeveloperFeaturesState.Changed += Reevaluate; } diff --git a/src/Client/vMenu.Enhanced.Menus/Players/PvpMode.cs b/src/Client/vMenu.Enhanced.Menus/Players/PvpMode.cs index 296f404d..0ed574ef 100644 --- a/src/Client/vMenu.Enhanced.Menus/Players/PvpMode.cs +++ b/src/Client/vMenu.Enhanced.Menus/Players/PvpMode.cs @@ -25,7 +25,7 @@ public static void Initialize() _initialized = true; - ClientConfig.Changed += Apply; + ClientConfig.AddEventListenerFor([Gameplay.PvpMode], Apply); Apply(); } diff --git a/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleCommands.cs b/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleCommands.cs index c50df547..88c3ec5c 100644 --- a/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleCommands.cs +++ b/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleCommands.cs @@ -50,7 +50,14 @@ public static class VehicleCommands /// Call after . public static void Initialize() { - ClientConfig.Changed += Apply; + ClientConfig.AddEventListenerFor( + [ + VehicleOptionsSettings.DeleteVehicleCommand, + VehicleOptionsSettings.RepairVehicleCommand, + VehicleOptionsSettings.WashVehicleCommand, + ], + Apply); + ClientPermissions.PermissionsChanged += Apply; Apply(); diff --git a/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleDumpCommands.cs b/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleDumpCommands.cs index 8948718d..a9a3c3d1 100644 --- a/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleDumpCommands.cs +++ b/src/Client/vMenu.Enhanced.Menus/Vehicles/VehicleDumpCommands.cs @@ -74,7 +74,7 @@ private static void Dump() Log.Info(ClientJson.SerializeIndented(appearance)); } - + private static void Labels() { if (CurrentVehicle() is not { } handle) diff --git a/src/Client/vMenu.Enhanced.Menus/World/WorldState.cs b/src/Client/vMenu.Enhanced.Menus/World/WorldState.cs index 5dad46c7..d8045667 100644 --- a/src/Client/vMenu.Enhanced.Menus/World/WorldState.cs +++ b/src/Client/vMenu.Enhanced.Menus/World/WorldState.cs @@ -16,39 +16,39 @@ namespace vMenu.Enhanced.Menus.World; /// The server's clock and overrides, read from the replicated convars. -// Polled rather than listened to: the clock convar changes every second, so a change listener would -// fire on every client forever to service a value only used when re-anchoring. public static class WorldState { private const string DumpCommand = "vmenu_world"; - // Twice the server's publish rate, which is the slowest that still sees every value it sends. - // Slower than this and reads start landing on the same second twice, so the clock re-anchors less - // often than it could. - private const int PollIntervalMs = 500; + // Only ever runs before the server's first value arrives + private const int FallbackIntervalMs = 500; /// Past this the server restarted or its clock jumped, so snap instead of easing. private const double HardResyncSeconds = 30.0; private const double DriftCorrection = 0.1; - private static float _speedMultiplier = ClientConfig.Value(TimeOptionsSettings.SpeedMultiplier); - public static int TimeTransitionSeconds = ClientConfig.Value(TimeOptionsSettings.TransitionSeconds); - public static int WeatherTransitionSeconds = ClientConfig.Value(WeatherOptionsSettings.TransitionSeconds); - public static bool SyncClouds = ClientConfig.Value(WeatherOptionsSettings.SyncClouds); + + private static float _speedMultiplier; private static double _anchorUnix; private static int _anchorTimerMs; private static bool _anchored; private static bool _warnedAboutFallback; private static bool _heardFromServer; - private static string _lastUtc = string.Empty; - private static string _lastWeather = string.Empty; - private static string _lastOffset = string.Empty; + private static TickHandle? _fallback; public static WeatherType? WeatherOverride { get; private set; } public static int TimeOffsetSeconds { get; private set; } + #region convars + public static int TimeTransitionSeconds { get; private set; } + + public static int WeatherTransitionSeconds { get; private set; } + + public static bool SyncClouds { get; private set; } + #endregion + public static bool HasClock => _anchored; public static event Action? Changed; @@ -57,8 +57,6 @@ public static class WorldState _anchored ? _anchorUnix + ((Native.GetGameTimer() - _anchorTimerMs) / 1000.0) : 0.0; /// How fast the clock runs, which the weather schedule follows as well. - // Read live rather than cached, so raising it takes effect without a restart. The same convar - // reaches every client, so nobody's sky runs at a different speed from anybody else's. public static double TimeSpeed => GameClock.ClampSpeed(_speedMultiplier); @@ -73,37 +71,43 @@ public static class WorldState public static WeatherType Weather => WeatherOverride ?? Schedule.Current; /// Whether either sync feature wants the clock. The same condition the server publishes on. - // Convars only, and deliberately no permission: the sky and the clock are the same for everybody - // on the server, so they cannot depend on what any one player is allowed to change. public static bool IsNeeded() => ClientConfig.Value(WeatherOptionsSettings.Enabled) || ClientConfig.Value(TimeOptionsSettings.Enabled); public static void Initialize() { - // Gated to match the server, which publishes nothing while both features are off. Without - // this every client on such a server falls back to its own machine clock and says so. - TickRegistry.Register( - "World.State", - Poll, - TickRate.Every(PollIntervalMs), - IsNeeded, - onStarted: Poll); - BrokenNatives.NativeFixer.AddConvarChangeListener(TimeOptionsSettings.SpeedMultiplier.Name, (string convar, object? newValue) => - { - _speedMultiplier = ClientConfig.GetFloat(convar) ?? _speedMultiplier; - }); - BrokenNatives.NativeFixer.AddConvarChangeListener(TimeOptionsSettings.TransitionSeconds.Name, (string convar, object? newValue) => - { - TimeTransitionSeconds = ClientConfig.GetInt(convar) ?? TimeTransitionSeconds; - }); - BrokenNatives.NativeFixer.AddConvarChangeListener(WeatherOptionsSettings.TransitionSeconds.Name, (string convar, object? newValue) => - { - WeatherTransitionSeconds = ClientConfig.GetInt(convar) ?? WeatherTransitionSeconds; - }); - BrokenNatives.NativeFixer.AddConvarChangeListener(WeatherOptionsSettings.SyncClouds.Name, (string convar, object? newValue) => - { - SyncClouds = ClientConfig.GetBool(convar) ?? SyncClouds; - }); + ClientConfig.Track(WorldStateConvars.All); + + ReadSettings(); + + // Get these values once, because listeners only trigger when values change + // after registering a listener for it. + ReadClock(); + ReadOverrides(); + + ClientConfig.AddEventListenerFor( + [ + TimeOptionsSettings.SpeedMultiplier, + TimeOptionsSettings.TransitionSeconds, + WeatherOptionsSettings.TransitionSeconds, + WeatherOptionsSettings.SyncClouds, + ], + ReadSettings); + + ClientConfig.AddEventListenerFor([WorldStateConvars.Utc], ReadClock); + ClientConfig.AddEventListenerFor([WorldStateConvars.Weather, WorldStateConvars.TimeOffset], ReadOverrides); + + // Gated to match the server, which publishes nothing while both features are off. + _fallback = TickRegistry.Register( + "World.Clock.Fallback", + AnchorFromLocalClock, + TickRate.Every(FallbackIntervalMs), + () => IsNeeded() && !_anchored); + + ClientConfig.AddEventListenerFor( + [WeatherOptionsSettings.Enabled, TimeOptionsSettings.Enabled], + _fallback.Reevaluate); + SharedAPI.Commands.RegisterCommand(DumpCommand, false, DebugCommands.Gate(Dump)); } @@ -137,55 +141,44 @@ public static void Dump() Log.Info($"[World] moon: {WorldTime.DescribeMoon()}"); } - private static void Poll() + private static void ReadSettings() { - var changed = false; - - var utc = Native.GetConvar(WorldStateConvars.Utc, string.Empty); + _speedMultiplier = ClientConfig.Value(TimeOptionsSettings.SpeedMultiplier); + TimeTransitionSeconds = ClientConfig.Value(TimeOptionsSettings.TransitionSeconds); + WeatherTransitionSeconds = ClientConfig.Value(WeatherOptionsSettings.TransitionSeconds); + SyncClouds = ClientConfig.Value(WeatherOptionsSettings.SyncClouds); + } - if (!string.Equals(utc, _lastUtc, StringComparison.Ordinal)) + private static void ReadClock() + { + if (!WorldStateConvars.TryParseUnix(Native.GetConvar(WorldStateConvars.Utc, string.Empty), out var published)) { - _lastUtc = utc; - - if (WorldStateConvars.TryParseUnix(utc, out var published)) - { - _heardFromServer = true; - - Anchor(published); - } + return; } - if (!_anchored) - { - AnchorFromLocalClock(); - } + _heardFromServer = true; - var weather = Native.GetConvar(WorldStateConvars.Weather, WorldStateConvars.Dynamic); + Anchor(published); - if (!string.Equals(weather, _lastWeather, StringComparison.Ordinal)) - { - _lastWeather = weather; - WeatherOverride = WeatherTypes.TryParse(weather, out var type) ? type : null; - changed = true; - } + // Whatever the machine clock had is now beaten by a real value, so the fallback is done. + _fallback?.Reevaluate(); + } + // No comparison against what was read last, because the module only calls this when the convar + // actually moved. + private static void ReadOverrides() + { + var weather = Native.GetConvar(WorldStateConvars.Weather, WorldStateConvars.Dynamic); var offset = Native.GetConvar(WorldStateConvars.TimeOffset, "0"); - if (!string.Equals(offset, _lastOffset, StringComparison.Ordinal)) - { - _lastOffset = offset; - TimeOffsetSeconds = WorldStateConvars.TryParseOffset(offset, out var seconds) ? seconds : 0; - changed = true; - } + WeatherOverride = WeatherTypes.TryParse(weather, out var type) ? type : null; + TimeOffsetSeconds = WorldStateConvars.TryParseOffset(offset, out var seconds) ? seconds : 0; - if (changed) - { - Changed?.Invoke(); - } + Changed?.Invoke(); } - // Corrected by a fraction of the drift rather than snapped: the server publishes once a second - // and this polls four times, so a snap would step the sky by up to thirty in-game seconds. + // Corrected by a fraction of the drift rather than snapped, because a snap on a server whose + // clock wobbles by a second would step the sky by thirty in-game seconds each time. private static void Anchor(double published) { if (!_anchored) @@ -233,5 +226,8 @@ private static void AnchorFromLocalClock() _anchorUnix = CivilTime.ToUnixSeconds(year, month, day, hour, minute, second); _anchorTimerMs = Native.GetGameTimer(); _anchored = true; + + // The loop only checks whether it is running, not its condition, so it has to be told. + _fallback?.Reevaluate(); } } diff --git a/src/Client/vMenu.Enhanced.Menus/World/WorldTime.cs b/src/Client/vMenu.Enhanced.Menus/World/WorldTime.cs index 93309fbe..ef125081 100644 --- a/src/Client/vMenu.Enhanced.Menus/World/WorldTime.cs +++ b/src/Client/vMenu.Enhanced.Menus/World/WorldTime.cs @@ -33,7 +33,7 @@ public static class WorldTime public static void Initialize() { - TickRegistry.Register( + var tick = TickRegistry.Register( "World.Time", Apply, // A manual time change sweeps hours in a couple of seconds, and at the steady rate that @@ -48,6 +48,8 @@ public static void Initialize() }, onStopped: Native.NetworkClearClockTimeOverride); + ClientConfig.AddEventListenerFor([TimeOptionsSettings.Enabled], tick.Reevaluate); + WorldState.Changed += TickRegistry.Reevaluate; } diff --git a/src/Client/vMenu.Enhanced.Menus/World/WorldWeather.cs b/src/Client/vMenu.Enhanced.Menus/World/WorldWeather.cs index 87673793..46d53a63 100644 --- a/src/Client/vMenu.Enhanced.Menus/World/WorldWeather.cs +++ b/src/Client/vMenu.Enhanced.Menus/World/WorldWeather.cs @@ -39,7 +39,7 @@ public static class WorldWeather public static void Initialize() { - TickRegistry.Register( + var tick = TickRegistry.Register( "World.Weather", Apply, TickRate.Every(IntervalMs), @@ -65,6 +65,8 @@ public static void Initialize() Native.SetWeatherOwnedByNetwork(true); }); + ClientConfig.AddEventListenerFor([WeatherOptionsSettings.Enabled], tick.Reevaluate); + WorldState.Changed += TickRegistry.Reevaluate; } diff --git a/src/Server/vMenu.Enhanced.Configuration.Server/ServerClock.cs b/src/Server/vMenu.Enhanced.Configuration.Server/ServerClock.cs index e04f846f..55cff8d9 100644 --- a/src/Server/vMenu.Enhanced.Configuration.Server/ServerClock.cs +++ b/src/Server/vMenu.Enhanced.Configuration.Server/ServerClock.cs @@ -45,7 +45,7 @@ public static class ServerClock public static void Initialize() { // onStarted rather than here, so nothing is published while both sync features are off. - ServerTickRegistry.Register( + var tick = ServerTickRegistry.Register( "Clock.Publish", Publish, TickRate.Every(PublishIntervalMs), @@ -53,6 +53,10 @@ public static void Initialize() onStarted: Publish, onStopped: Reset); + ServerConfig.AddEventListenerFor( + [WeatherOptionsSettings.Enabled, TimeOptionsSettings.Enabled], + tick.Reevaluate); + SharedAPI.Commands.RegisterCommand(DumpCommand, true, DebugCommands.Gate(Dump)); // Not behind the debug gate: this one changes the world rather than reporting on it, and an @@ -131,7 +135,7 @@ public static void Dump() DumpDate(now, speed, offset, secondOfDay); } - + private static void DumpDate(long now, double speed, int offset, double secondOfDay) { var day = (long)GameClock.Mod(GameClock.GameDay(now, offset, speed), MoonCycle.PeriodDays); diff --git a/src/Server/vMenu.Enhanced.Configuration.Server/ServerConfig.cs b/src/Server/vMenu.Enhanced.Configuration.Server/ServerConfig.cs index 5a0700fa..282411d4 100644 --- a/src/Server/vMenu.Enhanced.Configuration.Server/ServerConfig.cs +++ b/src/Server/vMenu.Enhanced.Configuration.Server/ServerConfig.cs @@ -15,12 +15,6 @@ public static class ServerConfig private static readonly ConfigStore Store = new(Native.GetConvar, Write); - public static event Action? Changed - { - add => Store.Changed += value; - remove => Store.Changed -= value; - } - /// Call once, first, from the server entry point. public static void Initialize() { @@ -28,8 +22,7 @@ public static void Initialize() ApplyLogLevel(); - // Any convar moving re-reads the level, because Changed does not say which one moved. - Store.Changed += ApplyLogLevel; + Store.Watch([Debugging.LogLevel], ApplyLogLevel); // One listener per convar rather than a single wildcard filter: an exact name cannot be // matched wrongly, and a filter that silently matches nothing would look like the whole @@ -42,6 +35,23 @@ public static void Initialize() SharedAPI.Commands.RegisterCommand(DumpCommand, true, DebugCommands.Gate(Dump)); } + /// Calls whenever any of these settings changes, and nothing else. + public static void AddEventListenerFor(IReadOnlyList settings, Action handler) => + Store.Watch(settings, handler); + + /// + /// Calls whenever any setting other than these changes. For a + /// subscriber that really does react to almost anything, where naming the settings it reads + /// would mean one added later silently never reaching it. + /// + public static void AddEventListenerExcept(IReadOnlyList settings, Action handler) => + Store.WatchExcept(settings, handler); + + public static void RemoveEventListenerFor(IReadOnlyList settings, Action handler) => + Store.Unwatch(settings, handler); + + public static void RemoveEventListenerExcept(Action handler) => Store.UnwatchExcept(handler); + /// Prints what the server currently reads for every setting. public static void Dump() { diff --git a/src/Server/vMenu.Enhanced.Core.Server/CoreServer.cs b/src/Server/vMenu.Enhanced.Core.Server/CoreServer.cs index 852fba73..2d7a4ac5 100644 --- a/src/Server/vMenu.Enhanced.Core.Server/CoreServer.cs +++ b/src/Server/vMenu.Enhanced.Core.Server/CoreServer.cs @@ -43,8 +43,6 @@ public void Initialize() ServerTickRegistry.Initialize(); - ServerConfig.Changed += ServerTickRegistry.Reevaluate; - ServerClock.Initialize(); ServerState.Initialize(); diff --git a/src/Shared/vMenu.Enhanced.Data/Configuration/ConfigStore.cs b/src/Shared/vMenu.Enhanced.Data/Configuration/ConfigStore.cs index 78c14d8b..d72daaa4 100644 --- a/src/Shared/vMenu.Enhanced.Data/Configuration/ConfigStore.cs +++ b/src/Shared/vMenu.Enhanced.Data/Configuration/ConfigStore.cs @@ -20,16 +20,19 @@ public sealed class ConfigStore(Func readConvar, Action< /// value. The typed GetConvarBool/Int/Float natives cannot do this: they collapse both /// cases into whatever default they were handed. /// - private const string Unset = "vMenu.Enhanced.Unset"; + private const string Unset = "vMenu.Enhanced.Unset"; private readonly Dictionary _cache = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _reported = new(StringComparer.OrdinalIgnoreCase); - private string[] _tracked = []; + private readonly Dictionary> _watchers = new(StringComparer.OrdinalIgnoreCase); + + private readonly List _exceptWatchers = []; + + private readonly HashSet _quiet = new(StringComparer.OrdinalIgnoreCase); - /// Raised once per actual change, whichever setting moved. - public event Action? Changed; + private string[] _tracked = []; /// The convars worth listening to, known only after . public IReadOnlyList Tracked => _tracked; @@ -55,7 +58,97 @@ public void Prime() log(ConfigLog.Debug, $"Tracking {_tracked.Length} setting(s)."); } - /// Re-reads and raises if it moved. + /// + /// Starts watching convars that are not settings, so + /// reaches them too. + /// + /// The names actually taken on, which is what the caller registers a native listener for. + /// + /// For the convars the server publishes world state through. Those cannot live in + /// , which is owner authored configuration and drives the generated + /// example file, so listing them there would invite editing state the server overwrites. They + /// are kept quiet as well: the clock moves once a second, so announcing every change would bury + /// the console, and never sees them so a subscriber that meant "any + /// setting an owner might change" is not woken once a second by the clock. + /// + public IReadOnlyList Track(IReadOnlyList convars) + { + var taken = new List(); + + foreach (var convar in convars) + { + if (!ConfigPath.IsValidName(convar)) + { + log(ConfigLog.Error, $"'{convar}' is not a usable convar name, so it can never be set."); + continue; + } + + if (!_cache.ContainsKey(convar)) + { + _cache[convar] = Raw(convar); + } + + _quiet.Add(convar); + taken.Add(convar); + } + + return taken; + } + + /// Calls whenever any of changes. + public void Watch(IReadOnlyList convars, Action handler) + { + foreach (var convar in convars) + { + // Staying silent would read as the listener working and the convar never moving, which + // is the one failure this module goes out of its way not to have. + if (!_cache.ContainsKey(convar)) + { + log(ConfigLog.Error, $"'{convar}' is not being watched, so a listener on it can never fire."); + continue; + } + + if (!_watchers.TryGetValue(convar, out var handlers)) + { + handlers = []; + _watchers[convar] = handlers; + } + + handlers.Add(handler); + } + } + + public void Watch(IReadOnlyList settings, Action handler) => Watch(Names(settings), handler); + + /// Calls whenever any setting other than these changes. + public void WatchExcept(IReadOnlyList settings, Action handler) + { + var excluded = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var setting in settings) + { + excluded.Add(setting.Name); + } + + _exceptWatchers.Add(new ExceptWatcher(excluded, handler)); + } + + public void Unwatch(IReadOnlyList convars, Action handler) + { + foreach (var convar in convars) + { + if (_watchers.TryGetValue(convar, out var handlers)) + { + handlers.Remove(handler); + } + } + } + + public void Unwatch(IReadOnlyList settings, Action handler) => Unwatch(Names(settings), handler); + + public void UnwatchExcept(Action handler) => _exceptWatchers.RemoveAll(watcher => watcher.Handler == handler); + + /// Re-reads and tells its listeners, if it moved. public void NotifyChanged(string convar) { if (!_cache.TryGetValue(convar, out var previous)) @@ -73,9 +166,35 @@ public void NotifyChanged(string convar) _cache[convar] = current; _reported.Remove(convar); - log(ConfigLog.Info, $"{convar} changed to {Quote(current)}."); + var quiet = _quiet.Contains(convar); - Changed?.Invoke(); + if (!quiet) + { + log(ConfigLog.Info, $"{convar} changed to {Quote(current)}."); + } + + // The listeners that named this convar go first, so one that caches the value has already + // refreshed it by the time a broad subscriber reads it back. + if (_watchers.TryGetValue(convar, out var handlers)) + { + foreach (var handler in handlers) + { + Invoke(convar, handler); + } + } + + if (quiet) + { + return; + } + + foreach (var watcher in _exceptWatchers) + { + if (!watcher.Excluded.Contains(convar)) + { + Invoke(convar, watcher.Handler); + } + } } /// One line per setting, for the vmenu_config command. @@ -114,6 +233,31 @@ public IEnumerable Describe() public float Value(FloatSetting setting) => GetFloat(setting.Name) ?? setting.Default; + private static string[] Names(IReadOnlyList settings) + { + var names = new string[settings.Count]; + + for (var index = 0; index < settings.Count; index++) + { + names[index] = settings[index].Name; + } + + return names; + } + + // A dispatch pass has to reach every listener, so one throwing must not take the rest with it. + private void Invoke(string convar, Action handler) + { + try + { + handler(); + } + catch (Exception exception) + { + log(ConfigLog.Error, $"A listener for {convar} threw: {exception}"); + } + } + private T? Typed(string convar, Func parse, string expected) where T : struct { var raw = Raw(convar); @@ -135,4 +279,11 @@ public IEnumerable Describe() } private static string Quote(string? value) => value is null ? "unset" : $"'{value}'"; + + private sealed class ExceptWatcher(HashSet excluded, Action handler) + { + public HashSet Excluded { get; } = excluded; + + public Action Handler { get; } = handler; + } } diff --git a/src/Shared/vMenu.Enhanced.Data/World/WorldStateConvars.cs b/src/Shared/vMenu.Enhanced.Data/World/WorldStateConvars.cs index c8918567..9e9fab87 100644 --- a/src/Shared/vMenu.Enhanced.Data/World/WorldStateConvars.cs +++ b/src/Shared/vMenu.Enhanced.Data/World/WorldStateConvars.cs @@ -13,6 +13,9 @@ public static class WorldStateConvars public const string TimeOffset = "vMenu.Enhanced.State.TimeOffset"; + /// All three, for handing to the configuration module in one go. + public static readonly string[] All = [Utc, Weather, TimeOffset]; + /// Weather is following the schedule. public const string Dynamic = "dynamic"; From 044be015122a7a895a99701e5ba7ea7ed243931c Mon Sep 17 00:00:00 2001 From: Tom Grobbe Date: Sat, 15 Aug 2026 14:16:51 +0200 Subject: [PATCH 3/3] tweak(online-players): listen for the revision convar instead of polling it --- .../vMenu.Enhanced.Menus/OnlinePlayersMenu.cs | 42 ++++++++++--------- .../Configuration/ConfigStore.cs | 9 ++-- .../OnlinePlayers/PlayerEvents.cs | 6 ++- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/Client/vMenu.Enhanced.Menus/OnlinePlayersMenu.cs b/src/Client/vMenu.Enhanced.Menus/OnlinePlayersMenu.cs index 639b0288..163dd229 100644 --- a/src/Client/vMenu.Enhanced.Menus/OnlinePlayersMenu.cs +++ b/src/Client/vMenu.Enhanced.Menus/OnlinePlayersMenu.cs @@ -6,14 +6,13 @@ using MenuAPI; using vMenu.Enhanced.Actions; +using vMenu.Enhanced.Configuration; using vMenu.Enhanced.Data.Actions; using vMenu.Enhanced.Data.OnlinePlayers; -using vMenu.Enhanced.Data.Ticks; using vMenu.Enhanced.Logging; using vMenu.Enhanced.MenuFramework; using vMenu.Enhanced.MenuFramework.Localization; using vMenu.Enhanced.Menus.Players; -using vMenu.Enhanced.Ticks; using OnlinePlayersPermissions = vMenu.Enhanced.Data.Permissions.Menus.OnlinePlayers; @@ -42,8 +41,6 @@ public sealed class OnlinePlayersMenu : MenuDefinition /// Long enough for the longest identifier anybody actually has. private const int SearchMaxLength = 64; - private const int StalenessPollMs = 1000; - /// How much of the search term the subtitle repeats back. private const int QueryDisplayLength = 16; @@ -57,7 +54,8 @@ public sealed class OnlinePlayersMenu : MenuDefinition private OnlinePlayer? _selected; - private TickHandle? _staleness; + /// Whether the list is on screen, since the staleness notice only makes sense there. + private bool _open; private string _query = string.Empty; @@ -106,7 +104,11 @@ protected override void Build(MenuBuilder menu) _actions.Builder.OnClosed = _ => _leftForActions = Native.GetGameTimer(); menu.OnOpened = _ => OnOpened(); - menu.OnClosed = _ => _staleness?.Stop(); + menu.OnClosed = _ => _open = false; + + // Not a setting, so the module has to be told about it before anything can listen for it. + ClientConfig.Track([PlayerEvents.RevisionConvar]); + ClientConfig.AddEventListenerFor([PlayerEvents.RevisionConvar], CheckStaleness); } private void OnOpened() @@ -116,6 +118,8 @@ private void OnOpened() return; } + _open = true; + // Rewritten here rather than declared once, so they follow a language change like everything // else does. MenuAPI's button hints hold a plain string, not a translation key. var localizer = Localizer.Current; @@ -124,16 +128,12 @@ private void OnOpened() menu.Menu.PreviousPageButtonText = localizer.Get(Loc.OnlinePlayers.PreviousPageButton); menu.Menu.NextPageButtonText = localizer.Get(Loc.OnlinePlayers.NextPageButton); - _staleness ??= TickRegistry.Register( - "OnlinePlayers.Staleness", - CheckStaleness, - TickRate.Every(StalenessPollMs), - autoStart: false); - - _staleness.Start(); - if (_leftForActions == Native.GetGameTimer()) { + // Opening the actions menu closed this one, so anything the list missed while it was + // down never reached the listener. This is the one path that keeps the old snapshot. + CheckStaleness(); + UpdateSubtitle(); return; @@ -184,7 +184,7 @@ private async Task RefreshAsync(string query) return byName != 0 ? byName : left.ServerId.CompareTo(right.ServerId); }); - _revisionAtBuild = Native.GetConvarInt(PlayerEvents.RevisionConvar, 0); + _revisionAtBuild = Revision(); _outdated = false; _hasSnapshot = true; @@ -600,15 +600,15 @@ private void OnPageChanged(Menu menu, int oldPage, int newPage, bool wrapped) private void CheckStaleness() { - // There is nothing to be out of date with until a list has actually arrived. This tick starts - // the moment the menu opens, while the first fetch is still in flight, so without this it - // compares the real revision against a zero nobody ever recorded. - if (!_hasSnapshot || _busy) + // There is nothing to be out of date with until a list has actually arrived, and nothing to + // say about it while the list is not on screen: the notice asks the player to reopen the + // menu, which reads as nonsense to somebody who does not have it open. + if (!_open || !_hasSnapshot || _busy) { return; } - if (_outdated || Native.GetConvarInt(PlayerEvents.RevisionConvar, 0) == _revisionAtBuild) + if (_outdated || Revision() == _revisionAtBuild) { return; } @@ -622,6 +622,8 @@ private void CheckStaleness() Notifications.Warning(MenuText.Key(Loc.OnlinePlayers.OutdatedNotice)); } + private static int Revision() => ClientConfig.GetInt(PlayerEvents.RevisionConvar) ?? 0; + private void UpdateSubtitle() { if (_menu is { } menu) diff --git a/src/Shared/vMenu.Enhanced.Data/Configuration/ConfigStore.cs b/src/Shared/vMenu.Enhanced.Data/Configuration/ConfigStore.cs index d72daaa4..bd9a64cf 100644 --- a/src/Shared/vMenu.Enhanced.Data/Configuration/ConfigStore.cs +++ b/src/Shared/vMenu.Enhanced.Data/Configuration/ConfigStore.cs @@ -64,13 +64,15 @@ public void Prime() /// /// The names actually taken on, which is what the caller registers a native listener for. /// - /// For the convars the server publishes world state through. Those cannot live in + /// For the convars the server publishes state through. Those cannot live in /// , which is owner authored configuration and drives the generated /// example file, so listing them there would invite editing state the server overwrites. They /// are kept quiet as well: the clock moves once a second, so announcing every change would bury /// the console, and never sees them so a subscriber that meant "any /// setting an owner might change" is not woken once a second by the clock. /// + // Names already being watched are skipped rather than taken on again, so a second caller cannot + // leave the same convar with two listeners on it, dispatching everything twice. public IReadOnlyList Track(IReadOnlyList convars) { var taken = new List(); @@ -83,11 +85,12 @@ public IReadOnlyList Track(IReadOnlyList convars) continue; } - if (!_cache.ContainsKey(convar)) + if (_cache.ContainsKey(convar)) { - _cache[convar] = Raw(convar); + continue; } + _cache[convar] = Raw(convar); _quiet.Add(convar); taken.Add(convar); } diff --git a/src/Shared/vMenu.Enhanced.Data/OnlinePlayers/PlayerEvents.cs b/src/Shared/vMenu.Enhanced.Data/OnlinePlayers/PlayerEvents.cs index 781cc0dd..528cc389 100644 --- a/src/Shared/vMenu.Enhanced.Data/OnlinePlayers/PlayerEvents.cs +++ b/src/Shared/vMenu.Enhanced.Data/OnlinePlayers/PlayerEvents.cs @@ -34,6 +34,8 @@ public static class PlayerEvents /// Replicated convar holding a number that changes whenever somebody joins or leaves. /// // A convar rather than a broadcast: it needs no event, and a client that connects halfway - // through reads the current value instead of having missed the announcement. - public const string RevisionConvar = "vmenu_players_revision"; + // through reads the current value instead of having missed the announcement. Named under the + // same root as everything else, because the configuration module only takes convars it can + // recognise as vMenu's own. + public const string RevisionConvar = "vMenu.Enhanced.State.PlayersRevision"; }