From 13f685ee94ae58901e9abbdbe84a215924c3341a Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 23 Aug 2026 14:34:37 +0200 Subject: [PATCH] tools(ace-mods): add @settime ACE mod for same-clock retail/acdream sky comparisons Sky and weather gates need retail and acdream looking at the SAME Dereth hour and the SAME authored day group; until now the only lever was acdream's ACDREAM_WORLD_TIME/ACDREAM_DAY_GROUP pins, which retail cannot follow, so the user could never judge night-sky differences (aurora, dome edge) side by side. @settime [Sunny|Rainy|Clear|Cloudy|] jumps ACE's Timers.PortalYearTicks FORWARD to the next matching instant. Every TimeSync header carries that value, so both clients re-sync within seconds. Forward-only because DelayActions compare against the same clock (a backward jump would stall them). The calendar and day-group roll duplicate acdream's retail ports (DerethCalendar shift 3600, SkyDayGroupSelector LCG from SkyDesc::CalcPresentDayGroup @0x00500E10) and the 20-entry name table was dumped from the installed Region DAT. Install: copy SetTime.dll + Meta.json to C:\ACE\Mods\SetTime and run @mod find (or restart ACE). Dev servers only. Co-Authored-By: Claude Fable 5 --- tools/ace-mods/SetTime/Meta.json | 10 ++ tools/ace-mods/SetTime/Mod.cs | 175 ++++++++++++++++++++++++++ tools/ace-mods/SetTime/README.md | 31 +++++ tools/ace-mods/SetTime/SetTime.csproj | 22 ++++ 4 files changed, 238 insertions(+) create mode 100644 tools/ace-mods/SetTime/Meta.json create mode 100644 tools/ace-mods/SetTime/Mod.cs create mode 100644 tools/ace-mods/SetTime/README.md create mode 100644 tools/ace-mods/SetTime/SetTime.csproj diff --git a/tools/ace-mods/SetTime/Meta.json b/tools/ace-mods/SetTime/Meta.json new file mode 100644 index 00000000..d516ea67 --- /dev/null +++ b/tools/ace-mods/SetTime/Meta.json @@ -0,0 +1,10 @@ +{ + "Name": "SetTime", + "Author": "acdream", + "Description": "@settime [Sunny|Rainy|Clear|Cloudy|] - jump the server's Dereth clock forward so every client (retail and acdream) sees the same hour and day group.", + "Version": "1.0", + "Priority": 0, + "Enabled": true, + "HotReload": true, + "RegisterCommands": true +} diff --git a/tools/ace-mods/SetTime/Mod.cs b/tools/ace-mods/SetTime/Mod.cs new file mode 100644 index 00000000..974eb7f3 --- /dev/null +++ b/tools/ace-mods/SetTime/Mod.cs @@ -0,0 +1,175 @@ +using System.Globalization; +using System.Reflection; +using ACE.Entity.Enum; +using ACE.Server.Command; +using ACE.Server.Entity; +using ACE.Server.Mods; +using ACE.Server.Network; +using ACE.Server.Network.GameMessages.Messages; + +namespace SetTime; + +/// +/// ACE mod host entry. Commands are discovered by attribute; nothing is patched. +/// +public class Mod : IHarmonyMod +{ + public void Initialize() { } + public void Dispose() { } +} + +/// +/// @settime: jump the server's Dereth clock FORWARD to a day fraction +/// (or hour), optionally to the next day whose authored day group rolls to +/// the requested weather. ACE writes Timers.PortalYearTicks into +/// every TimeSync packet header, so retail and acdream both follow within +/// seconds. +/// +/// Forward-only on purpose: DelayActions compare against +/// PortalYearTicks, so a backward jump would freeze every pending server +/// action until the clock caught up; a forward jump only fires them early +/// (acceptable on a dev server, never on a live shard). +/// +/// Calendar + day-group roll are acdream's retail ports +/// (DerethDateTime, SkyDayGroupSelector; retail +/// SkyDesc::CalcPresentDayGroup @0x00500E10): day = 7620 ticks, year +/// = 360 days, Dereth's ZeroTimeOfYear = 3600, seed = absoluteYear * +/// 360 + dayOfYear, LCG 0x6A42FDB2 / 0x8ABE1652, index = floor(20 * hash / +/// 2^32). Day-group names are Dereth's authored 20-group table. +/// +public static class SetTimeCommands +{ + private const double DayTicks = 7620.0; + private const double YearTicks = DayTicks * 360.0; + private const double ZeroTimeOfYear = 3600.0; + private const int ZeroYear = 10; + private const int DayGroupCount = 20; + + private static readonly string[] DayGroupNames = + [ + "Sunny", "Sunny", "Sunny", "Rainy", "Sunny", "Sunny", "Sunny", "Rainy", "Clear", "Rainy", + "Clear", "Clear", "Cloudy", "Cloudy", "Cloudy", "Rainy", "Rainy", "Rainy", "Rainy", "Rainy", + ]; + + [CommandHandler("settime", AccessLevel.Admin, CommandHandlerFlag.None, 1, + "Jumps the server's Dereth clock forward to an hour (0-23) or day fraction (0..1), optionally to the next day with the given day group (Sunny|Rainy|Clear|Cloudy|).", + " [Sunny|Rainy|Clear|Cloudy|]\nExamples: @settime 3 | @settime 0.125 Rainy | @settime 12 16")] + public static void HandleSetTime(Session session, params string[] parameters) + { + if (!TryParseFraction(parameters[0], out double fraction)) + { + Write(session, "settime: first argument must be an hour 0-23 or a day fraction in [0,1) written with a decimal point."); + return; + } + + Func? groupMatches = null; + string groupLabel = "any"; + if (parameters.Length > 1) + { + string want = parameters[1]; + if (int.TryParse(want, NumberStyles.Integer, CultureInfo.InvariantCulture, out int wantIndex)) + { + if (wantIndex < 0 || wantIndex >= DayGroupCount) + { + Write(session, $"settime: day group index must be 0..{DayGroupCount - 1}."); + return; + } + groupMatches = g => g == wantIndex; + groupLabel = $"{wantIndex} ({DayGroupNames[wantIndex]})"; + } + else + { + if (!DayGroupNames.Any(n => string.Equals(n, want, StringComparison.OrdinalIgnoreCase))) + { + Write(session, "settime: day group must be Sunny, Rainy, Clear, Cloudy or an index 0..19."); + return; + } + groupMatches = g => string.Equals(DayGroupNames[g], want, StringComparison.OrdinalIgnoreCase); + groupLabel = want; + } + } + + double now = Timers.PortalYearTicks; + long dayIndex = (long)Math.Floor((now + ZeroTimeOfYear) / DayTicks); + + double target = double.NaN; + int targetGroup = -1; + for (int d = 0; d <= 400; d++) + { + double candidate = (dayIndex + d) * DayTicks + fraction * DayTicks - ZeroTimeOfYear; + if (candidate <= now) + continue; + int g = DayGroupIndex(candidate); + if (groupMatches is null || groupMatches(g)) + { + target = candidate; + targetGroup = g; + break; + } + } + if (double.IsNaN(target)) + { + Write(session, $"settime: no day within 400 days rolls day group {groupLabel}."); + return; + } + + PropertyInfo ticks = typeof(Timers).GetProperty( + nameof(Timers.PortalYearTicks), + BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException("Timers.PortalYearTicks not found."); + ticks.SetValue(null, target); + + double jumpedDays = (target - now) / DayTicks; + Write(session, + $"settime: clock moved forward {jumpedDays:F2} Dereth days to day fraction {fraction:F4} " + + $"(hour {fraction * 24:F1}); day group {targetGroup} ({DayGroupNames[targetGroup]}). " + + $"PortalYearTicks {now:F0} -> {target:F0}. Clients re-sync on the next TimeSync."); + } + + private static bool TryParseFraction(string text, out double fraction) + { + fraction = 0; + if (!double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) + return false; + if (text.Contains('.')) + { + if (value < 0 || value >= 1) + return false; + fraction = value; + return true; + } + if (value >= 0 && value < 24) + { + fraction = value / 24.0; + return true; + } + return false; + } + + private static int DayGroupIndex(double ticksValue) + { + double shifted = ticksValue + ZeroTimeOfYear; + int year = (int)(shifted / YearTicks); + double tYear = shifted - year * YearTicks; + int dayOfYear = Math.Clamp((int)(tYear / DayTicks), 0, 359); + int absoluteYear = year + ZeroYear; + int seed = unchecked(absoluteYear * 360 + dayOfYear); + int mixed = unchecked(seed * 0x6A42FDB2 + unchecked((int)0x8ABE1652)); + float hash = mixed; + if (mixed < 0) + hash += 4294967296.0f; + const float inverseTwoTo32 = 1.0f / 4294967296.0f; + int index = (int)MathF.Floor(DayGroupCount * hash * inverseTwoTo32); + return index < 0 || index >= DayGroupCount ? 0 : index; + } + + private static void Write(Session? session, string message) + { + if (session is null) + { + Console.WriteLine(message); // console invocation + return; + } + session.Network.EnqueueSend(new GameMessageSystemChat(message, ChatMessageType.Broadcast)); + } +} diff --git a/tools/ace-mods/SetTime/README.md b/tools/ace-mods/SetTime/README.md new file mode 100644 index 00000000..bf857928 --- /dev/null +++ b/tools/ace-mods/SetTime/README.md @@ -0,0 +1,31 @@ +# SetTime — ACE mod for side-by-side sky/weather comparisons + +`@settime [Sunny|Rainy|Clear|Cloudy|]` + +Jumps the server's Dereth clock **forward** (never back) so retail and +acdream — both driven by ACE's TimeSync — show the same hour and the same +authored day group: + +- `@settime 3` → the next 3 o'clock (day fraction 0.125); +- `@settime 0.125 Rainy` → 3 o'clock on the next day whose roll is a Rainy group; +- `@settime 12 16` → noon on the next day that rolls day group 16. + +Dev servers only: a forward jump fires every pending `DelayAction` early. +The command needs `AccessLevel.Admin`. + +## Build and install + +```powershell +dotnet build tools/ace-mods/SetTime -c Release +New-Item -ItemType Directory -Force C:\ACE\Mods\SetTime | Out-Null +Copy-Item tools/ace-mods/SetTime/bin/Release/net10.0/SetTime.dll, tools/ace-mods/SetTime/Meta.json C:\ACE\Mods\SetTime\ +``` + +Then restart ACE (the mod loader also hot-reloads when `HotReload` is true). +`AceServerDir` (default `C:\ACE\Server`) can be overridden with +`-p:AceServerDir=...` if the server lives elsewhere. + +The calendar and day-group roll are the same retail ports acdream uses +(`DerethDateTime`, `SkyDayGroupSelector`; retail +`SkyDesc::CalcPresentDayGroup @0x00500E10`), so the group the command +predicts is the group both clients will render. diff --git a/tools/ace-mods/SetTime/SetTime.csproj b/tools/ace-mods/SetTime/SetTime.csproj new file mode 100644 index 00000000..b8538621 --- /dev/null +++ b/tools/ace-mods/SetTime/SetTime.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + SetTime + SetTime + false + false + false + false + C:\ACE\Server + + + $(AceServerDir)\ACE.Server.dllfalse + $(AceServerDir)\ACE.Entity.dllfalse + $(AceServerDir)\ACE.Common.dllfalse + +