diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index da66d8d0c..f9f5fbe15 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "modernuoschemagenerator": { - "version": "2.14.3", + "version": "4.0.0", "commands": [ "ModernUOSchemaGenerator" ] diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 1b3fb032b..eb74c39a4 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -40,13 +40,13 @@ jobs: with: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. - name: Install .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json - name: Install Prerequisites run: | brew update - brew install icu4c libdeflate zstd argon2 + brew install icu4c libdeflate argon2 - name: Set Library Path run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV - name: Build @@ -64,7 +64,7 @@ jobs: fi - name: Upload test results on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: TestResults-${{ matrix.name }} path: ./TestResults @@ -124,17 +124,41 @@ jobs: dnf config-manager --set-enabled crb dnf install -y epel-release if: ${{ matrix.epel }} + # Runtime packages only, deliberately. Installing the -dev packages here would add the + # unversioned .so symlink and mask the very thing the binding packages now probe for, so a + # regression in versioned-SONAME resolution would sail through CI. - name: Install Prerequisites using dnf - run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel liburing-devel + run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate libargon2 tzdata if: ${{ matrix.packageManager == 'dnf' }} + # ICU's runtime package carries the ABI version in its name (libicu70 on jammy, libicu76 on + # trixie) and has no stable alias, so match it by pattern. libicu-dev was the old way to stay + # version-independent, but it drags in the unversioned symlink and defeats the check below. - name: Install Prerequisites using apt - run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata liburing-dev + run: apt-get update -y && apt-get install -y curl '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata if: ${{ matrix.packageManager == 'apt' }} + # Versioned-SONAME resolution is only under test while the unversioned symlink is absent. If a + # base image or a package ever starts shipping it, every probe would succeed on the first try + # and a regression in the fallback would sail through CI, so fail loudly instead of silently + # testing nothing. + - name: Assert the unversioned .so symlinks are absent + run: | + found="" + for lib in libicuuc libicui18n libdeflate libargon2; do + hit=$(ls /usr/lib/*/"$lib".so /usr/lib64/"$lib".so 2>/dev/null || true) + if [ -n "$hit" ]; then + found="$found $hit" + fi + done + if [ -n "$found" ]; then + echo "::error::Unversioned symlinks present, so CI is no longer exercising versioned SONAME resolution:$found" + exit 1 + fi + echo "No unversioned symlinks present; versioned SONAME resolution is under test." - uses: actions/checkout@v7 with: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. - name: Install .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json - name: Build @@ -150,7 +174,7 @@ jobs: fi - name: Upload test results on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: TestResults-${{ matrix.name }} path: ./TestResults diff --git a/.github/workflows/build-tool-release.yml b/.github/workflows/build-tool-release.yml index 6cf70e799..696a825dc 100644 --- a/.github/workflows/build-tool-release.yml +++ b/.github/workflows/build-tool-release.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 # Full clone required for Nerdbank.GitVersioning - name: Install .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 270b9c1c6..3c0d182a3 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -15,7 +15,7 @@ jobs: fetch-depth: 0 # avoid shallow clone so nbgv can do its work. token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - name: Install .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json - name: Compute version diff --git a/.gitignore b/.gitignore index 7bb942e52..d5b26e268 100644 --- a/.gitignore +++ b/.gitignore @@ -9,16 +9,30 @@ /Distribution/bsdtar /Distribution/Configuration/antimacro.json /Distribution/Configuration/assistants.json +/Distribution/Configuration/auto-denylist.json +/Distribution/Configuration/bans.json +/Distribution/Configuration/blocklist.json +/Distribution/Configuration/crowdsec.json /Distribution/Configuration/expansion.json +/Distribution/Configuration/firewall.json +/Distribution/Configuration/ip-allowlist*.txt +/Distribution/Configuration/ip-allowlist*.txt.tmp +/Distribution/Configuration/ip-blocklist.txt +/Distribution/Configuration/ip-blocklist.txt.tmp +/Distribution/Configuration/login-allowlist.json +/Distribution/Configuration/login-allowlist.txt +/Distribution/Configuration/login-allowlist.txt.tmp /Distribution/Configuration/modernuo.json /Distribution/Configuration/email-settings.json /Distribution/Configuration/throttles.json /Distribution/Configuration/tot.json +/Distribution/Data/Pathfinding /Distribution/Logs /Distribution/Archives /Distribution/Backups /Distribution/Saves /Distribution/docs +/docs/ /Distribution/temp /Distribution/*.dylib /Distribution/*.so @@ -46,5 +60,7 @@ /packages/* /Distribution/Configuration/server-access.json -# BuildTool native binaries (downloaded from GitHub Releases) -/tools/ +# BuildTool native binaries (downloaded from GitHub Releases). +# Ignore everything under tools/ except the operator scripts checked in below. +/tools/* +!/tools/Export-IpBlocklist.ps1 diff --git a/CLAUDE.md b/CLAUDE.md index 5335c036d..c77d39e19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,14 +12,14 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 1. **LINQ** — Tier 1 (zero-cost patterns) free on hot paths; Tier 2 (low overhead) OK on warm paths; Tier 3 (allocating) forbidden on hot paths → `dev-docs/code-standards.md` 2. **No `Console.WriteLine`** — use `LogFactory.GetLogger(typeof(MyClass))` → `logger.Information(...)` (requires `using Server.Logging;`) -3. **No concurrency primitives** — no `lock`, `volatile`, `ConcurrentDictionary`, `Mutex`, etc. Server is single-threaded. +3. **Threading policy** — game logic runs only on the main loop; **never** touch game state (`World`, mobiles, items, maps, timers) from a background thread. Heavy work that *needs* game state must be **chunked** across ticks, not threaded. Heavy work that does *not* need game state (large-file parse, external I/O) **must** run on a background thread **and must yield to world saves** (defer while `World.Saving`/`WorldState.PendingSave`). Publish results back to the loop as an immutable snapshot swapped via a single `volatile` reference — the only sanctioned `volatile`. No `lock`/`Mutex`/`ConcurrentDictionary` in game logic. Rule #10 covers how background work hands results back to the loop → `dev-docs/threading-model.md` 4. **No `World.Mobiles`/`World.Items` iteration** — use spatial queries: `map.GetMobilesInRange()`, `map.GetItemsInRange()` 5. **Clean up refs in `OnDelete()`/`OnAfterDelete()`** — null out `Item`/`Mobile` references 6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()` 7. **`STArrayPool.Shared`** not `ArrayPool.Shared` — single-threaded optimized, no locks 8. **`PooledRefList`** not `new List()` on hot paths — zero GC pressure, stack-allocated ref struct -9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md` -10. **No `Task.Run`/`new Thread()`** in game code — game logic is single-threaded event loop +9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md` +10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). **Prove the need before adding a thread**: measure **on-loop** time, not wall-clock (frozen world is the cost, player latency is not), and gate on `Environment.ProcessorCount` — off-loading creates no CPU and buys nothing on 1–2 cores. New workers go in the vetted table in `dev-docs/threading-model.md` with their measurement. When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`, re-validating in the continuation whatever may have changed while it ran. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md` 11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target 12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code 13. **No empty gumps** — every gump must produce visual elements. An empty gump leaks on client+server (no way to close it). Use static `DisplayTo()` to validate before constructing → `dev-docs/gump-system.md` @@ -28,6 +28,8 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 16. **Prefer switch expressions and switch-when** — use switch expressions for value mapping and switch-when for pattern matching where they improve readability. Exception: skip if unreadable or cold path → `dev-docs/code-standards.md` 17. **No `System.Text.StringBuilder`** — use `ValueStringBuilder` with `stackalloc` (bounded output) or `ValueStringBuilder.Create()` (unbounded). Supports `$"..."` interpolation directly. Always use `using var` for disposal. Use `Reset()` instead of reassigning → `dev-docs/string-handling.md` 18. **Interpolation anti-patterns on handler-aware APIs** — `Send*`/`Say`/`Emote`/`PublicOverhead*`/`IPropertyList.Add`/gump `AddLabel`/`AddHtml`/`Html.Center`/`SpanWriter.Write*` all have `ref RawInterpolatedStringHandler` overloads that allocate zero strings, but only when the call-site argument is a `$"..."` literal directly. Avoid: ternaries with interpolated branches (`Send(c ? $"a" : $"b")`), switch expressions with interpolated arms, pre-built `var s = $"..."` locals (single-use), `.ToString()` / `.String()` / `string.Format` inside holes, string concat (`{a + b}`), LINQ string ops in holes. Use `:L` format spec for lowercase (`{rank:L}` not `rank.ToString().ToLowerInvariant()`) → `dev-docs/string-handling.md` § Interpolation Anti-Patterns +19. **No `InvalidateProperties()` from inside `GetProperties`** — every property a `GetProperties` override reads must be a pure read. `InvalidateProperties()` rebuilds the list in place (`Reset()` + rebuild), and `Reset()` returns the pooled interpolation buffer — which the compiler rents for the whole `$"..."` expression, so every hole is evaluated while it is live — and rewinds the packet cursor. A getter that invalidates therefore throws `ArgumentNullException` (parameter `"array"`) out of `GetProperties` from an unrelated-looking line, or silently corrupts the tooltip. The engine refuses and logs an error; `DEBUG` throws. Lazy recomputation in a getter is fine — the *notification* is not. Invalidate in the setter that changes the value, or defer with `Timer.DelayCall(InvalidateProperties)` → `dev-docs/property-lists.md` § Never Invalidate From Inside `GetProperties` +20. **Tick-count math must be wraparound-safe** — compare `Core.TickCount`/`GetTimestamp()` values only by subtraction (`a - b < 0`, never `a < b`), no zero/sign sentinels on tick fields, seed deadline fields from a real tick (never rely on the 0 default). Cloud hypervisors (GCP) pass through the host's never-resetting counter: ticks start enormous and can wrap negative. Linux affected in production; Windows not so far → `dev-docs/tick-counts.md` ## Dev-Docs Reference @@ -44,9 +46,14 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. | Commands & targeting | `dev-docs/commands-targeting.md` | | Event system | `dev-docs/events.md` | | Threading model | `dev-docs/threading-model.md` | +| Server hardware requirements | `dev-docs/server-requirements.md` | +| Debugging event-loop performance (profiling build, decomposition, GC/RAM) | `dev-docs/debugging-event-loop.md` | +| Tick-count overflow rules (subtraction comparisons; GCP pass-through counters) | `dev-docs/tick-counts.md` | | Server lifecycle & bootstrap phases (Configure/ConfigurePrompts/Initialize) | `dev-docs/server-lifecycle.md` | +| Platform prerequisites (ICU, tzdata, native libs per distro) | `dev-docs/platform-prerequisites.md` | | Configuration system | `dev-docs/configuration.md` | | Networking & packets | `dev-docs/networking-packets.md` | +| IP bans, blocklists & allowlists (incl. unblocking a player) | `dev-docs/ip-bans-and-allowlists.md` | | Region system | `dev-docs/regions.md` | | String handling & ValueStringBuilder | `dev-docs/string-handling.md` | | RunUO migration (overview) | `dev-docs/runuo-migration-docs/00-overview.md` | @@ -92,7 +99,18 @@ Then copy only the relevant skill files based on the task: | Migrate persistence (WorldSave) | `migrate-from-runuo/migrate-persistence` | | Migrate multi-file system | `migrate-from-runuo/migrate-systems` | -To enable a skill: `cp dev-docs/claude-skills/.md .claude/skills/` +To enable a skill — Claude Code loads `.claude/skills//SKILL.md`; a bare `.md` dropped +directly into `.claude/skills/` is **not** picked up, and newly installed skills appear in the +*next* session: + +```sh +# Standard skills (modernuo-*) +mkdir -p .claude/skills/ && cp dev-docs/claude-skills/.md .claude/skills//SKILL.md + +# Migration skills — sources live in the migrate-from-runuo/ subfolder, but install under the +# bare skill name (the table's "migrate-from-runuo/" is the source path, not the name): +mkdir -p .claude/skills/ && cp dev-docs/claude-skills/migrate-from-runuo/.md .claude/skills//SKILL.md +``` Migration skills reference the deep docs in `dev-docs/runuo-migration-docs/` and point to existing ModernUO skills for best practices. diff --git a/Directory.Build.props b/Directory.Build.props index 59311d439..c832f1006 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -63,8 +63,15 @@ ..\..\Rules.ruleset latest + + + $(DefineConstants);EVENT_LOOP_PROFILING + - + diff --git a/Distribution/Data/npc-speeds.json b/Distribution/Data/npc-speeds.json index 6e07859d5..707f61e4a 100644 --- a/Distribution/Data/npc-speeds.json +++ b/Distribution/Data/npc-speeds.json @@ -3,12 +3,16 @@ "level": "VerySlow", "active": 0.4, "passive": 0.8, + "activeMove": 0.9, + "passiveMove": 1.5, "types": [] }, { "level": "Slow", "active": 0.3, "passive": 0.6, + "activeMove": 0.6, + "passiveMove": 1.2, "types": [ "AntLion", "ArcticOgreLord", "BogThing", "Bogle", "BoneKnight", "EarthElemental", @@ -28,6 +32,8 @@ "level": "Medium", "active": 0.25, "passive": 0.5, + "activeMove": 0.45, + "passiveMove": 1.05, "types": [ "AcidElemental", "AgapiteElemental", "Alligator", "AncientLich", "Betrayer", "Bird", @@ -108,6 +114,8 @@ "level": "Fast", "active": 0.2, "passive": 0.4, + "activeMove": 0.3, + "passiveMove": 0.9, "types": [ "LordOaks", "Silvani", "AirElemental", "AncientWyrm", "Balron", "BladeSpirits", @@ -139,6 +147,8 @@ "level": "VeryFast", "active": 0.125, "passive": 0.30, + "activeMove": 0.125, + "passiveMove": 0.6, "types": [ "Barracoon", "Mephitis", "Neira", "Rikktor", "Semidar", "EnergyVortex", diff --git a/Projects/BuildTool/BuildOptions.cs b/Projects/BuildTool/BuildOptions.cs index 9aea5010d..9e1d56388 100644 --- a/Projects/BuildTool/BuildOptions.cs +++ b/Projects/BuildTool/BuildOptions.cs @@ -14,4 +14,11 @@ public sealed class BuildOptions public string? Arch { get; set; } public bool SkipPrereqs { get; set; } public bool Interactive { get; set; } + + /// + /// Report the native library prerequisites and exit. The interactive flow is the only other + /// path that runs these checks, so without this there is no way to verify a deployment target + /// from a script or a container. + /// + public bool CheckPrereqsOnly { get; set; } } diff --git a/Projects/BuildTool/BuildTool.csproj b/Projects/BuildTool/BuildTool.csproj index b3f37cf7a..983000240 100644 --- a/Projects/BuildTool/BuildTool.csproj +++ b/Projects/BuildTool/BuildTool.csproj @@ -17,6 +17,5 @@ - diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs index 5334e4a6a..95c521c24 100644 --- a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs +++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using BuildTool.Platform; using BuildTool.Publishing; @@ -31,8 +32,10 @@ public static class NativeLibraryChecker "Linux", [ ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0", - "Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev liburing-dev", - "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel liburing-devel", + "Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata", + " (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)", + " (add tzdata-legacy if the shard is configured with an alias such as US/Eastern)", + "Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu tzdata", "CentOS: Also requires epel-release and CRB enabled" ] ), @@ -176,82 +179,59 @@ public static class NativeLibraryChecker return results; } + /// + /// Native libraries the server needs from the system on Linux, and the SONAME range to accept + /// for each. Rationale and per-distro package names: dev-docs/platform-prerequisites.md. + /// + private static readonly (string Name, int MinSoVersion, int MaxSoVersion)[] _linuxLibraries = + [ + ("libicuuc", 60, 120), + ("libicui18n", 60, 120), + ("libdeflate", 0, 9), + ("libargon2", 0, 9) + ]; + private static List CheckLinux(PlatformInfo platform) - { - return platform.PackageManager switch - { - PackageManager.Apt => CheckLinuxApt(), - PackageManager.Dnf => CheckLinuxDnf(platform), - _ => CheckLinuxGeneric(platform) - }; - } - - private static List CheckLinuxApt() { var results = new List(); - var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev", "liburing-dev" }; var missing = new List(); - foreach (var package in packages) + foreach (var (name, minSoVersion, maxSoVersion) in _linuxLibraries) { - var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}"); - var installed = result.Success && result.StandardOutput.Contains("ii"); + var found = CanLoad(name, minSoVersion, maxSoVersion); - if (!installed) + if (!found) { - missing.Add(package); + missing.Add(name); } results.Add(new PrerequisiteResult { - Name = package, - Passed = installed, - Details = installed ? "Installed" : "Not installed" + Name = name, + Passed = found, + Details = found ? "Found" : "Not found" }); } - if (missing.Count > 0) + var hasTimeZoneData = HasTimeZoneData(); + if (!hasTimeZoneData) { - results.Add(new PrerequisiteResult - { - Name = "Install all missing", - Passed = false, - IsWarning = true, - Details = "Run the following command to install all missing dependencies:", - InstallCommand = $"sudo apt-get install -y {string.Join(' ', missing)}" - }); + missing.Add("tzdata"); } - return results; - } - - private static List CheckLinuxDnf(PlatformInfo platform) - { - var results = new List(); - var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel", "liburing-devel" }; - var missing = new List(); - - foreach (var package in packages) + results.Add(new PrerequisiteResult { - var result = ProcessRunner.RunCaptured("rpm", $"-q {package}"); - var installed = result.Success; + Name = "tzdata", + Passed = hasTimeZoneData, + Details = hasTimeZoneData ? "Found" : "Not found — every zone except UTC will throw" + }); - if (!installed) - { - missing.Add(package); - } - - results.Add(new PrerequisiteResult - { - Name = package, - Passed = installed, - Details = installed ? "Installed" : "Not installed" - }); + if (missing.Count == 0) + { + return results; } - // Check if this is CentOS (needs EPEL) - var isCentOs = platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true; - if (isCentOs && missing.Count > 0) + if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true) { results.Add(new PrerequisiteResult { @@ -263,49 +243,131 @@ public static class NativeLibraryChecker }); } - if (missing.Count > 0) + results.Add(new PrerequisiteResult { - results.Add(new PrerequisiteResult - { - Name = "Install all missing", - Passed = false, - IsWarning = true, - Details = "Run the following command to install all missing dependencies:", - InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}" - }); - } + Name = "Install all missing", + Passed = false, + IsWarning = true, + Details = "Install the missing dependencies. The -dev/-devel packages are not required:", + InstallCommand = BuildInstallCommand(platform, missing) + }); return results; } - private static List CheckLinuxGeneric(PlatformInfo platform) + /// + /// tzdata is data, not a library, so no loader probe finds it. Asking the runtime rather than + /// stat'ing a path keeps TZDIR honoured, and the count is still accurate under + /// InvariantGlobalization, which this tool runs with — only display names degrade there. + /// + private static bool HasTimeZoneData() { - var results = new List(); - - // Use ldconfig to check for shared libraries - var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p"); - var ldOutput = ldResult.Success ? ldResult.StandardOutput : ""; - - var libraries = new Dictionary + try { - ["libicu"] = "libicuuc", - ["libdeflate"] = "libdeflate", - ["zstd"] = "libzstd", - ["libargon2"] = "libargon2", - ["liburing"] = "liburing" - }; - - foreach (var (name, soName) in libraries) + return TimeZoneInfo.GetSystemTimeZones().Count > 1; + } + catch { - var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase); - results.Add(new PrerequisiteResult - { - Name = name, - Passed = found, - Details = found ? "Found" : "Not found — install using your package manager" - }); + return false; + } + } + + /// + /// Asks the loader directly rather than querying a package database or scanning ldconfig's + /// cache, both of which answer a different question and can disagree with what dlopen will do. + /// Mirrors the binding packages' own probing: the unversioned name first, then libfoo.so.N + /// descending. Bare names go through the full loader search path, so LD_LIBRARY_PATH and + /// /etc/ld.so.conf.d still apply. + /// + private static bool CanLoad(string library, int minSoVersion, int maxSoVersion) + { + if (TryLoadAndFree($"{library}.so")) + { + return true; } - return results; + for (var soVersion = maxSoVersion; soVersion >= minSoVersion; soVersion--) + { + if (TryLoadAndFree($"{library}.so.{soVersion}")) + { + return true; + } + } + + return false; + } + + private static bool TryLoadAndFree(string candidate) + { + if (!NativeLibrary.TryLoad(candidate, out var handle)) + { + return false; + } + + NativeLibrary.Free(handle); + return true; + } + + private static string BuildInstallCommand(PlatformInfo platform, List missing) + { + switch (platform.PackageManager) + { + case PackageManager.Apt: + { + // Distinct because the two ICU libraries resolve to the same package, and + // ResolveAptIcuPackage shells out, so it is memoized rather than called per name. + var packages = missing.Select( + library => library switch + { + "libdeflate" => "libdeflate0", + "libargon2" => "libargon2-1", + "tzdata" => "tzdata", + _ => _aptIcuPackage ??= ResolveAptIcuPackage() + } + ).Distinct(); + + return $"sudo apt-get install -y {string.Join(' ', packages)}"; + } + case PackageManager.Dnf: + { + var packages = missing.Select( + library => library switch + { + "libdeflate" => "libdeflate", + "libargon2" => "libargon2", + "tzdata" => "tzdata", + _ => "libicu" + } + ).Distinct(); + + return $"sudo dnf install -y {string.Join(' ', packages)}"; + } + default: + return $"Install your distribution's runtime packages for: {string.Join(", ", missing)}"; + } + } + + private static string _aptIcuPackage; + + /// + /// ICU's apt package carries the ABI version in its name and there is no stable alias, so ask + /// apt which one this release actually ships instead of printing a name that rots. + /// + private static string ResolveAptIcuPackage() + { + var result = ProcessRunner.RunCaptured("apt-cache", "search --names-only ^libicu[0-9]+$"); + if (!result.Success) + { + return "libicu"; + } + + var best = result.StandardOutput + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Split(' ', 2)[0].Trim()) + .Where(name => name.StartsWith("libicu", StringComparison.Ordinal)) + .OrderBy(name => int.TryParse(name.AsSpan(6), out var version) ? version : 0) + .LastOrDefault(); + + return best ?? "libicu"; } } diff --git a/Projects/BuildTool/Program.cs b/Projects/BuildTool/Program.cs index 3b36066da..915839bbf 100644 --- a/Projects/BuildTool/Program.cs +++ b/Projects/BuildTool/Program.cs @@ -4,6 +4,7 @@ using BuildTool.Interactive; using BuildTool.Platform; using BuildTool.Prerequisites; using BuildTool.Publishing; +using Spectre.Console; Console.OutputEncoding = Encoding.UTF8; @@ -36,6 +37,22 @@ options.Os ??= detectedPlatform.OsRid; options.Arch ??= detectedPlatform.ArchRid; var rid = $"{options.Os}-{options.Arch}"; +if (options.CheckPrereqsOnly) +{ + // Same renderer the guided menu uses, so the two cannot drift. Spectre drops ANSI styling by + // itself when stdout is not a terminal, which is the case this flag exists for, but it also + // falls back to an 80 column width and folds anything longer. The install hints we print are + // shell commands — the CentOS one is 95 characters — and a fold puts a newline in the middle of + // a command that someone is meant to copy. Widen the profile so they stay on one line. + if (Console.IsOutputRedirected) + { + AnsiConsole.Profile.Width = 200; + } + + // Exit code is the machine-readable half: 0 when everything resolves, 1 when anything is missing. + return PrerequisiteChecker.CheckNativeLibraries(detectedPlatform, interactive: false) ? 0 : 1; +} + // Run prerequisite checks unless skipped if (!options.SkipPrereqs) { @@ -108,6 +125,12 @@ static BuildOptions ParseArguments(string[] args) hasNamedArgs = true; break; } + case "--check-prereqs": + { + options.CheckPrereqsOnly = true; + hasNamedArgs = true; + break; + } case "--interactive": { options.Interactive = true; diff --git a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs index e84c16509..845efb115 100644 --- a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Reflection; using System.Threading; @@ -78,6 +79,15 @@ internal static class TestServerInitializer Core.LoopContext = new EventLoopContext(); Core.Expansion = Expansion.EJ; + // Seed the loop clock as Main.cs does before the Configure sweep; otherwise Core.Now is + // DateTime.MinValue for the whole test host. + Core._now = DateTime.UtcNow; + + // Timer wheel must exist before NetState.Configure(), which schedules a recurring + // sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs + // before AssemblyHandler.Invoke("Configure")). + Timer.Init(0); + // Configure networking (initializes RingSocketManager for tests) Server.Network.NetState.Configure(); @@ -87,8 +97,6 @@ internal static class TestServerInitializer // Configure the world World.Configure(); - Timer.Init(0); - // Load the world World.Load(); diff --git a/Projects/Server.Tests/Server.Tests.csproj b/Projects/Server.Tests/Server.Tests.csproj index 1be789258..4bae245cb 100644 --- a/Projects/Server.Tests/Server.Tests.csproj +++ b/Projects/Server.Tests/Server.Tests.csproj @@ -5,17 +5,16 @@ Server.Tests - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs index 0b76d3f94..d4777b758 100644 --- a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs +++ b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs @@ -4,6 +4,7 @@ using Xunit; namespace Server.Tests.Buffers; +[Collection("Sequential Server Tests")] public class RawInterpolatedStringHandlerTests { [Fact] diff --git a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs index 0e48977ef..59d736c6f 100644 --- a/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs +++ b/Projects/Server.Tests/Tests/Items/DecayRegistrationTests.cs @@ -208,6 +208,272 @@ public class DecayRegistrationTests item.Delete(); } + // Unfreezing an item with a stale LastMoved must grant a fresh decay window, + // not delete it on the next tick. + [Fact] + public void StaleImmovableItemMadeMovable_GetsAFreshDecayWindow() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(107, 100, 0), Map.Felucca); + + item.Movable = false; + Assert.False(DecayScheduler.IsRegistered(item), "A frozen item must not be tracked for decay."); + + Core._now = start + TimeSpan.FromDays(30); + var flipped = Core._now; + + item.Movable = true; + + Assert.True(DecayScheduler.IsRegistered(item), "An unfrozen item must be tracked for decay."); + + AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unfrozen item must get a full decay window, not vanish immediately."); + + AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unfrozen item must still decay once the fresh window elapses."); + } + finally + { + Core._now = start; + } + } + + // Same transition through the Visible setter: unhiding a long-hidden item. + [Fact] + public void StaleHiddenItemMadeVisible_GetsAFreshDecayWindow() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(109, 100, 0), Map.Felucca); + + item.Visible = false; + Assert.False(DecayScheduler.IsRegistered(item), "A hidden item must not be tracked for decay."); + + Core._now = start + TimeSpan.FromDays(30); + var flipped = Core._now; + + item.Visible = true; + + Assert.True(DecayScheduler.IsRegistered(item), "An unhidden item must be tracked for decay."); + + AdvanceDecay(flipped, item.DecayTime - TimeSpan.FromMinutes(2), item); + Assert.False(item.Deleted, "An unhidden item must get a full decay window, not vanish immediately."); + + AdvanceDecay(Core._now, TimeSpan.FromMinutes(4), item); + Assert.True(item.Deleted, "An unhidden item must still decay once the fresh window elapses."); + } + finally + { + Core._now = start; + } + } + + // A refusal restarts the countdown without rewriting LastMoved. + [Fact] + public void RefusedDecay_DoesNotRewriteLastMoved() + { + var start = Core._now; + + try + { + var item = new RefusesDecayItem(); + item.MoveToWorld(new Point3D(110, 100, 0), Map.Felucca); + var lastMoved = item.LastMoved; + + AdvanceDecay(start, item.DecayTime + TimeSpan.FromMinutes(2), item); + + Assert.False(item.Deleted, "A refused decay must not delete the item."); + Assert.True(DecayScheduler.IsRegistered(item), "A refused decay must leave the item tracked."); + Assert.Equal(lastMoved, item.LastMoved); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // The fresh window must survive a save/load cycle, or a restart mid-window deletes the item. + [Fact] + public void FreshDecayWindow_SurvivesSerialization() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(111, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + var expected = item.ScheduledDecayTime; + + var writer = new BufferWriter(new byte[512], true); + item.Serialize(writer); + + var copy = new Item(item.Serial); + copy.Deserialize(new BufferReader(writer.Buffer)); + + // The stamp is stored as a delta, so it ages only by the real time between + // write and read - milliseconds here, the downtime in production. + Assert.True( + (copy.ScheduledDecayTime - expected).Duration() <= TimeSpan.FromSeconds(5), + "The restarted decay window must survive a save/load cycle." + ); + + item.Delete(); + copy.Delete(); + } + finally + { + Core._now = start; + } + } + + // A real move supersedes the reset stamp; it must be dropped so the CompactInfo can collapse. + [Fact] + public void MovingAnItem_ClearsASupersededDecayResetStamp() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(112, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + Core._now += TimeSpan.FromMinutes(1); + item.MoveToWorld(new Point3D(113, 100, 0), Map.Felucca); + + Assert.Equal(default, item.DecayResetTime); + Assert.Equal(item.LastMoved + item.DecayTime, item.ScheduledDecayTime); + Assert.True(DecayScheduler.IsRegistered(item)); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // Losing decay eligibility makes the stamp meaningless; it must be dropped so the + // CompactInfo is not held for as long as the item stays ineligible. + [Fact] + public void ItemBecomingIneligible_DropsTheDecayResetStamp() + { + var start = Core._now; + + try + { + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(115, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + item.Movable = false; + + Assert.Equal(default, item.DecayResetTime); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + // Moving a stamped item into a container programmatically (no drop, no SetLastMoved) + // must also drop the stamp. + [Fact] + public void StampedItemAddedToContainer_DropsTheDecayResetStamp() + { + var start = Core._now; + + try + { + var pack = new Container(0xE75); + pack.MoveToWorld(new Point3D(116, 100, 0), Map.Felucca); + + var item = new Item(0x1234); + item.MoveToWorld(new Point3D(117, 100, 0), Map.Felucca); + + item.Movable = false; + Core._now = start + TimeSpan.FromDays(30); + item.Movable = true; + + Assert.NotEqual(default, item.DecayResetTime); + + pack.AddItem(item); + + Assert.Equal(default, item.DecayResetTime); + + pack.Delete(); + } + finally + { + Core._now = start; + } + } + + // A raw Map assignment (e.g. a GM changing Map through props) is a move: it must + // enroll an untracked item for decay. + [Fact] + public void ItemMovedToRealMapViaMapSetter_IsRegisteredForDecay() + { + var item = new Item(0x1234); + Assert.False(DecayScheduler.IsRegistered(item)); + + item.Map = Map.Felucca; + + Assert.True(item.CanDecay()); + Assert.True(DecayScheduler.IsRegistered(item), "Item placed on a map via the Map setter must be tracked."); + + item.Delete(); + } + + // LiftItemDupe places the remainder of a partially lifted ground stack via raw + // Location/Map assignments, with no MoveToWorld fallback: it must still be tracked. + [Fact] + public void PartialLiftOfGroundStack_LeavesRemainderRegisteredForDecay() + { + var stack = new Item(0x1234) { Stackable = true, Amount = 10 }; + stack.MoveToWorld(new Point3D(114, 100, 0), Map.Felucca); + + var remainder = Mobile.LiftItemDupe(stack, 3); + + Assert.NotNull(remainder); + Assert.Equal(7, remainder.Amount); + Assert.Null(remainder.Parent); + Assert.Equal(Map.Felucca, remainder.Map); + Assert.True( + DecayScheduler.IsRegistered(remainder), + "The remainder of a partially lifted ground stack must be tracked for decay." + ); + + stack.Delete(); + remainder.Delete(); + } + // Dropping into a container must untrack; taking it back out to the ground must re-track. [Fact] public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay() diff --git a/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs new file mode 100644 index 000000000..955a87293 --- /dev/null +++ b/Projects/Server.Tests/Tests/Items/PlayerConstructedStackingTests.cs @@ -0,0 +1,95 @@ +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class PlayerConstructedStackingTests +{ + // PlayerConstructed is per-instance provenance, and stack operations were written when no + // item carried any. Merging keeps the receiver's copy of a field and splitting rebuilds one + // half from a fixed list of fields, so a flag that is not accounted for in both places is + // one that ordinary stacking can launder or erase. + + // Stands in for a real stackable type. LiftItemDupe builds the remainder through the + // parameterless constructor and copies only a fixed list of fields onto it -- Stackable is + // not on that list -- so the remainder is only stackable if the type restores it the way + // every genuine stackable does. + private class StackableItem : Item + { + public StackableItem() => Stackable = true; + + public StackableItem(Serial serial) : base(serial) => Stackable = true; + } + + private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) => + new(serial) { Amount = amount, PlayerConstructed = playerConstructed }; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CanStackWith_IsTrueWhenProvenanceMatches(bool playerConstructed) + { + var first = MakeStack((Serial)0x1, 5, playerConstructed); + var second = MakeStack((Serial)0x2, 7, playerConstructed); + + try + { + Assert.True(first.CanStackWith(second)); + } + finally + { + first.Delete(); + second.Delete(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LiftItemDupe_CopiesPlayerConstructedToRemainder(bool playerConstructed) + { + var stack = MakeStack((Serial)0x1, 10, playerConstructed); + Item remainder = null; + + try + { + remainder = Mobile.LiftItemDupe(stack, 4); + + Assert.NotNull(remainder); + Assert.NotSame(stack, remainder); + Assert.Equal(4, stack.Amount); + Assert.Equal(6, remainder.Amount); + Assert.Equal(playerConstructed, remainder.PlayerConstructed); + } + finally + { + stack.Delete(); + remainder?.Delete(); + } + } + + [Fact] + public void SplitHalvesRemainStackableWithEachOther() + { + // The two halves of a split must still be one pile's worth: if the split dropped the + // flag, the remainder would no longer stack back onto what it came from. + var stack = MakeStack((Serial)0x1, 10, true); + Item remainder = null; + + try + { + remainder = Mobile.LiftItemDupe(stack, 4); + Assert.NotNull(remainder); + + Assert.True(stack.CanStackWith(remainder)); + Assert.True(stack.StackWith(null, remainder, false)); + Assert.Equal(10, stack.Amount); + Assert.True(stack.PlayerConstructed); + } + finally + { + stack.Delete(); + remainder?.Delete(); + } + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs new file mode 100644 index 000000000..5eb1895d8 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/BanChannelTests.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class BanChannelTests +{ + private sealed class FakeReporter : IBanReporter + { + public readonly List<(IPAddress ip, TimeSpan ttl, string reason)> Reports = []; + public readonly List Retractions = []; + public bool ThrowOnReport; + + public string Name => "fake"; + public bool CanRetract => true; + public void Register() { } + public void Start(CancellationToken token) { } + public void Stop() { } + + public void Report(IPAddress address, TimeSpan ttl, string reason) + { + if (ThrowOnReport) + { + throw new InvalidOperationException("boom"); + } + + Reports.Add((address, ttl, reason)); + } + + public void Retract(IPAddress address) => Retractions.Add(address); + } + + [Fact] + public void Report_FansOutToAllReporters() + { + var a = new FakeReporter(); + var b = new FakeReporter(); + BanChannel.ConfigureForTesting([a, b]); + + BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "rate-limit"); + + Assert.Single(a.Reports); + Assert.Single(b.Reports); + Assert.Equal("rate-limit", a.Reports[0].reason); + } + + [Fact] + public void Report_SwallowsReporterException() + { + var bad = new FakeReporter { ThrowOnReport = true }; + var good = new FakeReporter(); + BanChannel.ConfigureForTesting([bad, good]); + + BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "manual"); + + Assert.Single(good.Reports); // the throwing reporter does not block the others + } + + [Fact] + public void Report_SuppressedForExemptAddress() + { + var reporter = new FakeReporter(); + BanChannel.ConfigureForTesting([reporter]); + + var exempt = IPAddress.Parse("203.0.113.7"); + BanChannel.IsExempt = (ip, _) => ip.Equals(exempt); + + try + { + BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit"); + Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted + + BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit"); + Assert.Single(reporter.Reports); // everyone else is still contributed + } + finally + { + BanChannel.IsExempt = null; + } + } + + [Fact] + public void Retract_ReachesRetractCapableReporters() + { + var a = new FakeReporter(); + BanChannel.ConfigureForTesting([a]); + + BanChannel.Retract(IPAddress.Parse("9.9.9.9")); + + Assert.Single(a.Retractions); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs new file mode 100644 index 000000000..ee232b1df --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/Bans/BanConfigurationTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Text.Json; +using Server.Json; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests; + +public class BanConfigurationTests +{ + // Locks the JsonConfig casing/converter contract: JsonConfig's options are case-SENSITIVE, so + // every settings member must carry an explicit [JsonPropertyName("camelCase")] or it silently + // binds nothing. These tests round-trip through the exact options the loader uses. + + [Fact] + public void BanSettings_RoundTripsThroughJsonConfig() + { + var original = new BanSettings + { + ReportRateLimitTrips = false, + AutoBanDuration = TimeSpan.FromHours(2) + }; + + var json = JsonConfig.Serialize(original); + + Assert.Contains("\"reportRateLimitTrips\"", json); + Assert.Contains("\"autoBanDuration\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.ReportRateLimitTrips, restored.ReportRateLimitTrips); + Assert.Equal(original.AutoBanDuration, restored.AutoBanDuration); // TimeSpan survives + } + + [Fact] + public void BanSettings_Defaults_AreReportRateLimitTripsFourHourAutoBan() + { + var settings = new BanSettings(); + + Assert.True(settings.ReportRateLimitTrips); + Assert.Equal(TimeSpan.FromHours(4), settings.AutoBanDuration); + } +} diff --git a/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs b/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs new file mode 100644 index 000000000..b7e134794 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/ConnectionFiltersTests.cs @@ -0,0 +1,133 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ConnectionFiltersTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Net; +using System.Threading; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +[Collection("Sequential Server Tests")] +public class ConnectionFiltersTests : IDisposable +{ + public ConnectionFiltersTests() => ConnectionFilters.ResetForTesting(); + + public void Dispose() => ConnectionFilters.ResetForTesting(); + + [Fact] + public void No_filters_denies_nothing() + { + Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy)); + Assert.Null(deniedBy); + } + + [Fact] + public void Register_is_idempotent_by_name() + { + ConnectionFilters.Register(new FakeFilter("dupe", deny: false)); + ConnectionFilters.Register(new FakeFilter("dupe", deny: true)); + + // The second registration is ignored, so the deny:true instance never gets consulted. + Assert.Single(ConnectionFilters.Filters); + Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _)); + } + + [Fact] + public void First_denying_filter_short_circuits_and_is_named() + { + var first = new FakeFilter("allow-all", deny: false); + var second = new FakeFilter("deny-all", deny: true); + var third = new FakeFilter("never-reached", deny: true); + + ConnectionFilters.Register(first); + ConnectionFilters.Register(second); + ConnectionFilters.Register(third); + + Assert.True(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy)); + Assert.Equal("deny-all", deniedBy); + Assert.Equal(1, first.Calls); + Assert.Equal(1, second.Calls); + Assert.Equal(0, third.Calls); // short-circuited + } + + // A filter that throws once throws for every subsequent connection, which would turn one bug into an + // exception per accept. It must be dropped, and the connection must fail open rather than be denied + // by a filter that never actually answered. + [Fact] + public void Throwing_filter_is_unregistered_and_fails_open() + { + var bad = new FakeFilter("bad", deny: true, throws: true); + var good = new FakeFilter("good", deny: false); + + ConnectionFilters.Register(bad); + ConnectionFilters.Register(good); + + Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _)); + Assert.Single(ConnectionFilters.Filters); + Assert.Equal("good", ConnectionFilters.Filters[0].Name); + + // Remaining filters still run on the same pass the faulty one was dropped in. + Assert.Equal(1, good.Calls); + } + + [Fact] + public void Register_configures_immediately() + { + var filter = new FakeFilter("cfg", deny: false); + ConnectionFilters.Register(filter); + + Assert.True(filter.Configured); + } + + private sealed class FakeFilter : IConnectionFilter + { + private readonly bool _deny; + private readonly bool _throws; + + public FakeFilter(string name, bool deny, bool throws = false) + { + Name = name; + _deny = deny; + _throws = throws; + } + + public string Name { get; } + public int Calls { get; private set; } + public bool Configured { get; private set; } + + public void Register() => Configured = true; + + public void Start(CancellationToken token) + { + } + + public void Stop() + { + } + + public bool ShouldDeny(IPAddress address) + { + Calls++; + if (_throws) + { + throw new InvalidOperationException("simulated filter bug"); + } + + return _deny; + } + } +} diff --git a/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs b/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs new file mode 100644 index 000000000..dfb8c7be7 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/EventLoopIdleTests.cs @@ -0,0 +1,69 @@ +using Xunit; + +namespace Server.Tests; + +/// +/// The event loop only sleeps when every queue it drains is empty. These drains are deliberately +/// bounded -- ExecuteTasks stops at its per-frame cap -- so leftover work is normal and must keep +/// the loop awake. Getting this wrong strands queued work for the length of a sleep. +/// +[Collection("Sequential Server Tests")] +public class EventLoopIdleTests +{ + [Fact] + public void FreshContextIsEmpty() + { + var context = new EventLoopContext(); + + Assert.True(context.IsEmpty); + } + + [Fact] + public void PostedWorkMakesContextNonEmpty() + { + var context = new EventLoopContext(); + + context.Post(() => { }); + + Assert.False(context.IsEmpty); + } + + [Fact] + public void PriorityWorkMakesContextNonEmpty() + { + var context = new EventLoopContext(); + + context.Post(() => { }, EventLoopContext.Priority.High); + + Assert.False(context.IsEmpty); + } + + [Fact] + public void ContextIsEmptyAgainOnceDrained() + { + var context = new EventLoopContext(); + context.Post(() => { }); + + context.ExecuteTasks(); + + Assert.True(context.IsEmpty); + } + + [Fact] + public void WorkBeyondThePerFrameCapKeepsContextNonEmpty() + { + // The cap is what makes IsEmpty necessary: a single ExecuteTasks pass cannot be assumed + // to have drained everything, so the loop must not treat "I just ran tasks" as "idle". + const int perFrameCap = 128; + var context = new EventLoopContext(perFrameCap); + + for (var i = 0; i < perFrameCap + 10; i++) + { + context.Post(() => { }); + } + + context.ExecuteTasks(); + + Assert.False(context.IsEmpty); + } +} diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs deleted file mode 100644 index f803ee586..000000000 --- a/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs +++ /dev/null @@ -1,137 +0,0 @@ - -using System.Net; -using System.Threading.Tasks; -using Server.Network; -using Xunit; - -namespace Server.Tests; - -public class FirewallTests -{ - [Fact] - public void Firewall_BlocksIPAddress_WhenAdded() - { - var ip = IPAddress.Parse("192.168.1.1"); - var entry = new SingleIpFirewallEntry("192.168.1.1"); - - Assert.False(Firewall.IsBlocked(ip)); - - Firewall.Add(entry); - - Assert.True(Firewall.IsBlocked(ip)); - } - - [Fact] - public void Firewall_DoesNotBlockIPAddress_WhenNotAdded() - { - var ip = IPAddress.Parse("192.168.1.2"); - Assert.False(Firewall.IsBlocked(ip)); - } - - [Fact] - public void Firewall_StopsBlockingIPAddress_WhenRemoved() - { - var ip = IPAddress.Parse("192.168.1.3"); - var entry = new SingleIpFirewallEntry("192.168.1.3"); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); - } - - [Fact] - public void Firewall_BlocksIPRange() - { - var entry = new CidrFirewallEntry(IPAddress.Parse("10.0.0.1"), IPAddress.Parse("10.0.0.5")); - - Firewall.Add(entry); - - Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.1"))); - Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.3"))); - Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.5"))); - - Assert.False(Firewall.IsBlocked(IPAddress.Parse("10.0.0.6"))); - } - - [Fact] - public void Firewall_CacheInvalidation_WorksOnUpdate() - { - var ip = IPAddress.Parse("192.168.1.10"); - var entry = new SingleIpFirewallEntry("192.168.1.10"); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); - } - - [Fact] - public void Firewall_ReadsFirewallSetCorrectly() - { - var entry = new SingleIpFirewallEntry("172.16.0.1"); - Firewall.Add(entry); - - var found = false; - Firewall.ReadFirewallSet(set => - { - found = set.Contains(entry); - }); - - Assert.True(found); - } - - [Fact] - public void Firewall_IsThreadSafe() - { - var testIps = new IPAddress[256]; - for (var i = 0; i <= 255; i++) - { - testIps[i] = IPAddress.Parse($"192.168.0.{i}"); - } - - var entry = new CidrFirewallEntry(IPAddress.Parse("192.168.0.1"), IPAddress.Parse("192.168.0.255")); - Firewall.Add(entry); - - Parallel.ForEach(testIps, ip => - { - var shouldBlock = int.Parse(ip.ToString().Split('.')[3]) is > 0; - Assert.Equal(shouldBlock, Firewall.IsBlocked(ip)); - }); - - Firewall.Remove(entry); - - Parallel.ForEach(testIps, ip => - { - Assert.False(Firewall.IsBlocked(ip)); - }); - } - - [Fact] - public void Firewall_DoesNotThrowWhenRemovingNonExistentEntry() - { - var entry = new SingleIpFirewallEntry("203.0.113.5"); - Assert.False(Firewall.Remove(entry)); - } - - [Fact] - public void Firewall_CacheHandlesMultipleUpdates() - { - var ip = IPAddress.Parse("192.168.1.20"); - var entry = new SingleIpFirewallEntry("192.168.1.20"); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); - - Firewall.Add(entry); - Assert.True(Firewall.IsBlocked(ip)); - - Firewall.Remove(entry); - Assert.False(Firewall.IsBlocked(ip)); - } -} diff --git a/Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs b/Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs new file mode 100644 index 000000000..95a3c4114 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/ForeignProtocolTests.cs @@ -0,0 +1,116 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ForeignProtocolTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Text; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +public class ForeignProtocolTests +{ + private static ForeignProtocolMatch Identify(byte[] bytes, out ForeignProtocolKind kind) => + ForeignProtocol.Identify(bytes, out kind); + + private static byte[] Ascii(string s) => Encoding.ASCII.GetBytes(s); + + [Theory] + [InlineData("GET / HTTP/1.1\r\n")] + [InlineData("POST /a HTTP/1.1\r\n")] + [InlineData("HEAD / HTTP/1.0\r\n")] + [InlineData("OPTIONS * HTTP/1.1\r\n")] + [InlineData("CONNECT host:443 HTTP/1.1\r\n")] + [InlineData("DELETE /x HTTP/1.1\r\n")] + public void Http_requests_are_identified(string request) + { + Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(Ascii(request), out var kind)); + Assert.Equal(ForeignProtocolKind.Http, kind); + } + + [Fact] + public void Ssh_banner_is_identified() + { + Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(Ascii("SSH-2.0-OpenSSH_9.6"), out var kind)); + Assert.Equal(ForeignProtocolKind.Ssh, kind); + } + + [Fact] + public void Tls_client_hello_is_identified() + { + // handshake, TLS 1.2 record, length 0x0100, ClientHello + byte[] hello = [0x16, 0x03, 0x03, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFC]; + + Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(hello, out var kind)); + Assert.Equal(ForeignProtocolKind.Tls, kind); + } + + [Fact] + public void Tls_prefix_without_a_client_hello_is_not_foreign() + { + // A seed can spell 0x16 0x03 0x0? -- the address 22.3.x.x. Byte five is a packet id, not a + // handshake type, so it must fall through to normal parsing. + byte[] seedThenLogin = [0x16, 0x03, 0x03, 0x04, 0x00, 0x80, 0x00, 0x00]; + + Assert.Equal(ForeignProtocolMatch.None, Identify(seedThenLogin, out var kind)); + Assert.Equal(ForeignProtocolKind.None, kind); + } + + [Fact] + public void Seed_that_spells_an_http_method_falls_through() + { + // Seed 0x47455420 spells "GET ". The next byte is the 0x80 login packet id, not printable, so this + // is a real client and must not be flagged. + byte[] seedThenLogin = [(byte)'G', (byte)'E', (byte)'T', (byte)' ', 0x80, 0x00, 0x3A, 0x00]; + + Assert.Equal(ForeignProtocolMatch.None, Identify(seedThenLogin, out _)); + } + + [Theory] + [InlineData(0x80)] // login request + [InlineData(0x91)] // game server login + [InlineData(0xEF)] // new-style seed packet + public void Ordinary_uo_openings_are_not_foreign(byte secondPacketId) + { + byte[] buffer = [0x7F, 0x00, 0x00, 0x01, secondPacketId, 0x00, 0x00, 0x00]; + + Assert.Equal(ForeignProtocolMatch.None, Identify(buffer, out _)); + } + + [Fact] + public void Encrypted_login_is_not_mistaken_for_a_foreign_protocol() + { + // A legitimate client with encryption on when the shard expects none. The login cipher is a + // byte-for-byte XOR, so this is noise of exactly the right length. + byte[] buffer = [0x7F, 0x00, 0x00, 0x01, 0xC3, 0x9A, 0x04, 0xE1, 0x55, 0xB2]; + + Assert.Equal(ForeignProtocolMatch.None, Identify(buffer, out _)); + } + + [Fact] + public void Prefix_match_without_enough_bytes_waits() + { + // Framing is never assumed: "GET " split from its request line must wait. + Assert.Equal(ForeignProtocolMatch.Incomplete, Identify(Ascii("GET "), out _)); + Assert.Equal(ForeignProtocolMatch.Incomplete, Identify(Ascii("GET /"), out _)); + } + + [Fact] + public void Too_few_bytes_to_match_a_prefix_is_not_foreign() + { + // Under four bytes the caller's own short-read handling applies. + Assert.Equal(ForeignProtocolMatch.None, Identify(Ascii("GE"), out _)); + Assert.Equal(ForeignProtocolMatch.None, Identify([], out _)); + } +} diff --git a/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs b/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs new file mode 100644 index 000000000..c1bc6a800 --- /dev/null +++ b/Projects/Server.Tests/Tests/Network/NetworkCompressionBoundsTests.cs @@ -0,0 +1,86 @@ +using System; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network; + +/// +/// Bounds behaviour of the Huffman compressor when the destination is too small. +/// +/// This is reachable in production: NetState only checks that the send buffer has *some* writable +/// space before handing the remainder to Compress, so a nearly-full buffer can offer a span of one +/// to three bytes. The internal guard is computed as an unsigned output.Length - 4, which +/// underflows for those sizes and stops bounding the writes at all. +/// +public class NetworkCompressionBoundsTests +{ + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void RefusesOutputTooSmallToBound(int outputSize) + { + var input = new byte[64]; + Array.Fill(input, (byte)'A'); + + // Sentinel-filled backing array; only the middle window is offered to the compressor, so + // any write past the span shows up as a modified sentinel rather than silent corruption. + var backing = new byte[256]; + Array.Fill(backing, (byte)0xCC); + + const int windowStart = 64; + var output = backing.AsSpan(windowStart, outputSize); + + var written = NetworkCompression.Compress(input, output); + + Assert.Equal(0, written); + + for (var i = 0; i < backing.Length; i++) + { + Assert.Equal(0xCC, backing[i]); + } + } + + [Fact] + public void StillCompressesWhenOutputIsLargeEnough() + { + var input = new byte[64]; + Array.Fill(input, (byte)'A'); + + var output = new byte[256]; + + var written = NetworkCompression.Compress(input, output); + + Assert.True(written > 0); + Assert.True(written <= output.Length); + } + + [Fact] + public void ReportsFailureRatherThanOverrunningATightOutput() + { + // Large input against a small-but-bounded output: the guard is well-defined here, so this + // must fail cleanly rather than write past the end. + var input = new byte[4096]; + Array.Fill(input, (byte)'A'); + + var backing = new byte[256]; + Array.Fill(backing, (byte)0xCC); + + const int windowStart = 64; + const int windowSize = 16; + var output = backing.AsSpan(windowStart, windowSize); + + NetworkCompression.Compress(input, output); + + for (var i = 0; i < windowStart; i++) + { + Assert.Equal(0xCC, backing[i]); + } + + for (var i = windowStart + windowSize; i < backing.Length; i++) + { + Assert.Equal(0xCC, backing[i]); + } + } +} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs new file mode 100644 index 000000000..a30e9c9e3 --- /dev/null +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListReentrancyTests.cs @@ -0,0 +1,145 @@ +using System; +using Xunit; + +namespace Server.Tests; + +/// +/// The interpolation buffer is rented by the handler ctor and returned by the closing Add, so every +/// hole is evaluated while it is live. A Reset()/Dispose() landing in that window used to leave the +/// next Append* spanning a null array: ArgumentNullException, parameter "array". +/// +// Sequential: building a list rents from STArrayPool, which is not thread-safe. +[Collection("Sequential Server Tests")] +public class ObjectPropertyListReentrancyTests +{ + // Stands in for a property getter that invalidates while its own tooltip is being built. + private static string ResettingHole(ObjectPropertyList list, string value) + { + list.Reset(); + return value; + } + + private static string DisposingHole(ObjectPropertyList list, string value) + { + list.Dispose(); + return value; + } + + [Fact] + public void InterpolatedAdd_ResetMidHole_DoesNotThrow() + { + var opl = new ObjectPropertyList(null); + + var ex = Record.Exception( + () => opl.Add(1060776, $"{ResettingHole(opl, "Knight")}\t{"Council of Mages"}") + ); + + Assert.Null(ex); + } + + [Fact] + public void InterpolatedAdd_DisposeMidHole_DoesNotThrow() + { + var opl = new ObjectPropertyList(null); + + var ex = Record.Exception( + () => opl.Add(1060776, $"{DisposingHole(opl, "Knight")}\t{"Council of Mages"}") + ); + + Assert.Null(ex); + } +} + +/// +/// The guard is per-list, so nested builds (a GetProperties override that reads another entity's +/// PropertyList) cannot unguard the outer one the way a single shared slot would. +/// +[Collection("Sequential Server Tests")] +public class ObjectPropertyListNestedBuildTests +{ + [Fact] + public void NestedBuild_DoesNotUnguardTheOuterList() + { + var outer = new ObjectPropertyList(null); + var inner = new ObjectPropertyList(null); + + outer.IsBuilding = true; + inner.IsBuilding = true; // another entity starts building, and finishes + inner.IsBuilding = false; + + Assert.True(outer.IsBuilding); + } + + [Fact] + public void Reset_MidInterpolation_LeavesTheListUsable() + { + var opl = new ObjectPropertyList(null); + + opl.Add(1060776, $"{Reset(opl, "Knight")}\t{"Council of Mages"}"); + opl.Add(1042971, "still working"); + opl.Terminate(); + + Assert.NotNull(opl.Buffer); + } + + private static string Reset(ObjectPropertyList list, string value) + { + list.Reset(); + return value; + } +} + + +/// +/// Invalidating from inside GetProperties is a defect in the getter, not a case to recover from: +/// DEBUG throws, RELEASE keeps a possibly stale tooltip without crashing or leaking. +/// +[Collection("Sequential Server Tests")] +public class PropertyListInvalidationDuringBuildTests +{ + private class SelfInvalidatingMobile : Mobile + { + public int Builds; + + public override void GetProperties(IPropertyList list) + { + Builds++; + base.GetProperties(list); + InvalidateProperties(); + list.Add(1060776, $"{"Knight"}\t{"Council of Mages"}"); + } + } + + private static SelfInvalidatingMobile Place(int x) + { + var m = new SelfInvalidatingMobile(); + m.MoveToWorld(new Point3D(x, 1000, 0), Map.Felucca); + return m; + } + + [Fact] + public void InvalidatingFromGetProperties_FailsLoudlyWithoutTearingDownTheBuild() + { + var wasEnabled = ObjectPropertyList.Enabled; + ObjectPropertyList.Enabled = true; + + try + { + var m = Place(1000); + +#if DEBUG + Assert.Throws(() => _ = m.PropertyList); +#else + Assert.Null(Record.Exception(() => _ = m.PropertyList)); +#endif + + // Refused, not retried. + Assert.Equal(1, m.Builds); + m.Delete(); + } + finally + { + ObjectPropertyList.Enabled = wasEnabled; + } + } +} diff --git a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs index 408c53386..97e2d9f58 100644 --- a/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs +++ b/Projects/Server.Tests/Tests/PropertyList/ObjectPropertyListSpanAddTests.cs @@ -62,6 +62,21 @@ public class ObjectPropertyListSpanAddTests Assert.Equal((1070722, "Custom"), entries[0]); } + [Fact] + public void HashFormat_OnlyMarksIntegers() + { + // Integer {value:#} emits the cliloc marker "#". + var intList = new ObjectPropertyList(null); + intList.Add(1062028, $"{1043009:#}"); + Assert.Equal((1062028, "#1043009"), Decode(intList)[0]); + + // Float {value:#} is the standard '#' custom-numeric (digit-placeholder) format, not a cliloc + // marker -- so no leading '#'. + var dblList = new ObjectPropertyList(null); + dblList.Add(1062028, $"{42.0:#}"); + Assert.Equal((1062028, 42.0.ToString("#")), Decode(dblList)[0]); // "42" + } + [Fact] public void Add_TruncatesArgumentOverMaxLength() { diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs new file mode 100644 index 000000000..7889c57c8 --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs @@ -0,0 +1,76 @@ +using System; +using Xunit; + +namespace Server.Tests; + +[Collection("Sequential Server Tests")] +public class AnchoredItemSerializationTests +{ + private static byte[] SerializeItem(Item item) + { + var writer = new BufferWriter(new byte[256], true); + item.Serialize(writer); + return writer.Buffer[..(int)writer.Position]; + } + + /// + /// Item v11 stores LastMoved and DecayResetTime as anchored time: the serialized bytes + /// are a function of item state only, not of when the save runs. Pre-v11 stored + /// minutes-since-moved and delta time, which rewrote the bytes on every save. + /// + [Fact] + public void ItemBytes_AreStable_AcrossSavesAtDifferentTimes() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.MoveToWorld(new Point3D(120, 100, 0), Map.Felucca); + item.RestartDecay(); + + var first = SerializeItem(item); + + // A save hours later, with no state change, must produce identical bytes. + Core._now = start + TimeSpan.FromHours(5); + var second = SerializeItem(item); + + Assert.Equal(first, second); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// Pre-v11 LastMoved was stored at whole-minute precision relative to the save time and + /// could never round-trip exactly. Anchored storage is absolute and exact. + /// + [Fact] + public void LastMovedAndDecayReset_RoundTripExactly() + { + var item = new Item(0x1F13); + item.MoveToWorld(new Point3D(121, 100, 0), Map.Felucca); + + // Sub-minute precision that the old minutes encoding would have destroyed. + var moved = Core.Now - TimeSpan.FromSeconds(90.5) - TimeSpan.FromMilliseconds(123); + item.LastMoved = moved; + + item.RestartDecay(); + var decayReset = item.DecayResetTime; + Assert.NotEqual(default(DateTime), decayReset); + + var bytes = SerializeItem(item); + + var restored = new Item((Serial)0x7ffff123u); + restored.Deserialize(new BufferReader(bytes)); + + Assert.Equal(moved, restored.LastMoved); + Assert.Equal(decayReset, restored.DecayResetTime); + + item.Delete(); + } +} diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs new file mode 100644 index 000000000..a99b9850c --- /dev/null +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Server.Tests; + +public class AnchoredTimeTests +{ + private static (BufferWriter Writer, Func Read) CreateRoundTrip() + { + var writer = new BufferWriter(new byte[64], true); + return (writer, shift => new BufferReader(writer.Buffer) { AnchoredTimeShift = shift }); + } + + [Fact] + public void AnchoredTime_RoundTripsExactly_WithZeroShift() + { + var (writer, read) = CreateRoundTrip(); + var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); + + writer.WriteAnchoredTime(value); + + Assert.Equal(value, read(TimeSpan.Zero).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_AppliesShiftOnRead() + { + var (writer, read) = CreateRoundTrip(); + var value = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Utc); + var shift = TimeSpan.FromHours(3); + + writer.WriteAnchoredTime(value); + + Assert.Equal(value + shift, read(shift).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_SentinelsPassThroughUnshifted() + { + var (writer, read) = CreateRoundTrip(); + + writer.WriteAnchoredTime(DateTime.MinValue); + writer.WriteAnchoredTime(DateTime.MaxValue); + + var reader = read(TimeSpan.FromDays(2)); + Assert.Equal(DateTime.MinValue, reader.ReadAnchoredTime()); + Assert.Equal(DateTime.MaxValue, reader.ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_SaturatesInsteadOfOverflowing() + { + var (writer, read) = CreateRoundTrip(); + + writer.WriteAnchoredTime(DateTime.MaxValue - TimeSpan.FromMinutes(1)); + + Assert.Equal(DateTime.MaxValue, read(TimeSpan.FromDays(1)).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_NormalizesLocalKindOnWrite() + { + var (writer, read) = CreateRoundTrip(); + var local = new DateTime(2026, 8, 22, 12, 30, 0, DateTimeKind.Local); + + writer.WriteAnchoredTime(local); + + Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime()); + } +} + +internal class AnchoredEntity : ISerializable +{ + public AnchoredEntity(Serial serial) => Serial = serial; + + public Serial Serial { get; } + public DateTime Created { get; set; } = DateTime.UtcNow; + public bool Deleted => false; + + public DateTime LastRested { get; set; } + + public void Delete() + { + } + + public void Serialize(IGenericWriter writer) => writer.WriteAnchoredTime(LastRested); + + public void Deserialize(IGenericReader reader) => LastRested = reader.ReadAnchoredTime(); +} + +[Collection("Sequential Server Tests")] +public class AnchoredTimePersistenceTests +{ + private class AnchoredPersistence : GenericEntityPersistence + { + public AnchoredPersistence(int priority) : base("AnchoredTrip", priority, 1, 0x7FFFFFFF) + { + } + } + + /// + /// The idx v5 header carries the save-start anchor; loading re-bases anchored timestamps + /// by the elapsed time since the save started, so downtime does not age them. + /// + [Fact] + public void SaveStartAnchor_RebasesAnchoredTimestampsAtLoad() + { + var previousAssemblies = AssemblyHandler.Assemblies; + AssemblyHandler.Assemblies = [.. previousAssemblies ?? [], typeof(AnchoredEntity).Assembly]; + + var source = new SerializationChunkSource(); + var workers = new SerializationThreadWorker[2]; + for (var i = 0; i < workers.Length; i++) + { + workers[i] = new SerializationThreadWorker(i, source); + workers[i].AllocateHeap(); + } + + var previousWorkers = World._threadWorkers; + World._threadWorkers = workers; + + var previousSaveStart = World.SaveStartTime; + + var persistence = new AnchoredPersistence(2100); + AnchoredPersistence loaded = null; + + var dir = Path.Combine(Path.GetTempPath(), $"muo-anchored-{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + + try + { + var lastRested = Core.Now - TimeSpan.FromMinutes(10); + var serial = (Serial)1u; + persistence.EntitiesBySerial[serial] = new AnchoredEntity(serial) { LastRested = lastRested }; + persistence.RegisterType(typeof(AnchoredEntity)); + + // Pretend the save started two hours ago, as if the server had been down since. + var downtime = TimeSpan.FromHours(2); + World.SaveStartTime = Core.Now - downtime; + + foreach (var worker in workers) + { + worker.Wake(); + } + + source.SetOwner(persistence); + Assert.True(persistence.TrySnapshotEntries(out var slotCount)); + source.PushSlotRanges(persistence, slotCount); + + source.Flush(); + foreach (var worker in workers) + { + worker.Sleep(); + } + + persistence.WriteSnapshot(dir); + persistence.PostWorldSave(); + + loaded = new AnchoredPersistence(2101); + loaded.DeserializeIndexes(dir, null); + loaded.Deserialize(dir, null); + + var entity = loaded.EntitiesBySerial[serial]; + var expected = lastRested + downtime; + + Assert.True( + (entity.LastRested - expected).Duration() <= TimeSpan.FromSeconds(30), + $"Anchored timestamp must re-base by the downtime; expected ~{expected}, got {entity.LastRested}." + ); + } + finally + { + World.SaveStartTime = previousSaveStart; + persistence.Unregister(); + loaded?.Unregister(); + + foreach (var worker in workers) + { + worker.Exit(); + } + + World._threadWorkers = previousWorkers; + AssemblyHandler.Assemblies = previousAssemblies; + Directory.Delete(dir, true); + } + } +} diff --git a/Projects/Server/Collections/SortedRangeIndex.cs b/Projects/Server/Collections/SortedRangeIndex.cs new file mode 100644 index 000000000..5af37fd59 --- /dev/null +++ b/Projects/Server/Collections/SortedRangeIndex.cs @@ -0,0 +1,134 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: SortedRangeIndex.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Numerics; + +namespace Server.Collections; + +/// +/// Immutable, allocation-lean membership index over a set of inclusive integer ranges. Ranges are +/// stored as two parallel arrays (_mins/_maxs) sorted by minimum and coalesced into +/// disjoint runs, so a single binary search decides membership. Coalescing is required for +/// correctness: only inspects the rightmost run whose minimum is <= the +/// value, which is only sound when the runs never overlap or nest. +/// +public sealed class SortedRangeIndex where T : IBinaryInteger +{ + public static readonly SortedRangeIndex Empty = new([], []); + + /// An inclusive [Min, Max] range. Singles are represented as Min == Max. + public readonly record struct Range(T Min, T Max); + + /// Orders ranges ascending by minimum; the coalescing pass in requires this. + public static readonly Comparison ByMin = static (a, b) => a.Min.CompareTo(b.Min); + + private readonly T[] _mins; + private readonly T[] _maxs; + + private SortedRangeIndex(T[] mins, T[] maxs) + { + _mins = mins; + _maxs = maxs; + } + + public int Count => _mins.Length; + + /// + /// True when falls in any range. Binary-searches for the rightmost run + /// whose minimum is <= the value, then tests that value against that run's maximum. + /// + public bool Contains(T value) + { + var lo = 0; + var hi = _mins.Length - 1; + var found = -1; + while (lo <= hi) + { + var mid = (lo + hi) >> 1; + if (_mins[mid] <= value) + { + found = mid; + lo = mid + 1; + } + else + { + hi = mid - 1; + } + } + + return found >= 0 && value <= _maxs[found]; + } + + /// + /// Builds an index from ranges that are already sorted ascending by (sort + /// the source with first). Overlapping and nested ranges are merged into disjoint + /// runs. Two passes over the (pooled) input keep the run count exact so only the two final arrays are + /// heap-allocated: pass one counts the runs, pass two fills the exact-size arrays. + /// + public static SortedRangeIndex Build(ReadOnlySpan sortedByMin) + { + if (sortedByMin.IsEmpty) + { + return Empty; + } + + // Pass 1: count the disjoint runs so the final arrays can be sized exactly. + var runs = 1; + var curMax = sortedByMin[0].Max; + for (var i = 1; i < sortedByMin.Length; i++) + { + var r = sortedByMin[i]; + if (r.Min <= curMax) + { + if (r.Max > curMax) + { + curMax = r.Max; + } + } + else + { + runs++; + curMax = r.Max; + } + } + + // Pass 2: write the coalesced runs into the exact-size final arrays. + var mins = new T[runs]; + var maxs = new T[runs]; + var w = 0; + mins[0] = sortedByMin[0].Min; + maxs[0] = sortedByMin[0].Max; + for (var i = 1; i < sortedByMin.Length; i++) + { + var r = sortedByMin[i]; + if (r.Min <= maxs[w]) + { + if (r.Max > maxs[w]) + { + maxs[w] = r.Max; + } + } + else + { + w++; + mins[w] = r.Min; + maxs[w] = r.Max; + } + } + + return new SortedRangeIndex(mins, maxs); + } +} diff --git a/Projects/Server/Diagnostics/EventLoopProfiler.cs b/Projects/Server/Diagnostics/EventLoopProfiler.cs new file mode 100644 index 000000000..615a84afd --- /dev/null +++ b/Projects/Server/Diagnostics/EventLoopProfiler.cs @@ -0,0 +1,234 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: EventLoopProfiler.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Server; + +public enum LoopPhase +{ + MobileDeltas, + ItemDeltas, + TimerSlice, + NetworkSlice, + LoopTasks, + WorldSnapshot, +} + +/// +/// Event-loop time accounting, compiled out of normal builds. Build with +/// -p:EventLoopProfiling=true to enable; every hook is +/// [Conditional("EVENT_LOOP_PROFILING")], so without the flag the call sites do not exist +/// in the IL and this class is dormant. See dev-docs/debugging-event-loop.md for how to read it. +/// +/// +/// Each one-second sample decomposes wall time into work (per ), sleep, +/// GC pause, and a stolen residual (wall - work - sleep): time the host ran something else. +/// Samples land in a ring buffer (~15 minutes) so a lag episode can be compared against the good +/// minutes on the same box, build, and world — the baseline RunUO's profiler never had. +/// +public static class EventLoopProfiler +{ + public const int PhaseCount = 6; + private const int RingSize = 900; + private const long SampleIntervalMs = 1000; + + public struct Sample + { + public long WallStart; // Core.TickCount at sample start + public long WallMs; // sample length + public long Iterations; + public long Sleeps; + public double SleepMs; // total time blocked in WaitForCompletion + public double SleepOvershootMaxMs; // worst (elapsed - requested) this sample + public long LateWakes; // overshoot >= Timer.TickRate + public long WheelLagMaxMs; // worst wheel lateness observed at Slice entry + public long WakesIssued; + public long WakesElided; + public double GcPauseMs; // GC.GetTotalPauseDuration delta + public int Gen0; + public int Gen1; + public int Gen2; + public PhaseTimes Phases; + + // Work the phases did not account for and the loop did not spend sleeping: host + // scheduling steals, and anything between the bracketed phases. GC pauses inside a + // phase or sleep inflate those measurements instead, so GcPauseMs overlaps rather + // than subtracts. + public double StolenMs + { + get + { + var known = SleepMs + Phases.Total; + return WallMs > known ? WallMs - known : 0; + } + } + } + + [InlineArray(PhaseCount)] + public struct PhaseTimes + { + private double _element0; + + public double Total + { + get + { + double total = 0; + for (var i = 0; i < PhaseCount; i++) + { + total += this[i]; + } + + return total; + } + } + } + + private static readonly double _msPerTick = 1000.0 / Stopwatch.Frequency; + + private static Sample[] _ring; + private static int _ringCount; + private static int _ringHead; + + private static Sample _current; + private static long _phaseStartTimestamp; + private static long _sampleStartedAt; + private static TimeSpan _lastGcPause; + private static int _lastGen0; + private static int _lastGen1; + private static int _lastGen2; + + /// Number of samples recorded so far (capped at the ring size). + public static int SampleCount => _ringCount; + + /// The sample currently being accumulated (not yet in the ring). + public static Sample Current => _current; + + /// + /// Copies the newest completed samples, oldest first. + /// + public static Sample[] History(int count = RingSize) + { + count = Math.Min(count, _ringCount); + var result = new Sample[count]; + for (var i = 0; i < count; i++) + { + result[i] = _ring[(_ringHead - count + i + RingSize) % RingSize]; + } + + return result; + } + + [Conditional("EVENT_LOOP_PROFILING")] + public static void IterationStart(long tickCount) + { + if (_ring == null) + { + _ring = new Sample[RingSize]; + _sampleStartedAt = tickCount; + _current.WallStart = tickCount; + _lastGcPause = GC.GetTotalPauseDuration(); + _lastGen0 = GC.CollectionCount(0); + _lastGen1 = GC.CollectionCount(1); + _lastGen2 = GC.CollectionCount(2); + } + + _current.Iterations++; + + if (tickCount - _sampleStartedAt < SampleIntervalMs) + { + return; + } + + _current.WallMs = tickCount - _sampleStartedAt; + + var pause = GC.GetTotalPauseDuration(); + _current.GcPauseMs = (pause - _lastGcPause).TotalMilliseconds; + _lastGcPause = pause; + + var gen0 = GC.CollectionCount(0); + var gen1 = GC.CollectionCount(1); + var gen2 = GC.CollectionCount(2); + _current.Gen0 = gen0 - _lastGen0; + _current.Gen1 = gen1 - _lastGen1; + _current.Gen2 = gen2 - _lastGen2; + _lastGen0 = gen0; + _lastGen1 = gen1; + _lastGen2 = gen2; + + _ring[_ringHead] = _current; + _ringHead = (_ringHead + 1) % RingSize; + if (_ringCount < RingSize) + { + _ringCount++; + } + + _sampleStartedAt = tickCount; + _current = default; + _current.WallStart = tickCount; + } + + [Conditional("EVENT_LOOP_PROFILING")] + public static void PhaseStart(LoopPhase phase) => _phaseStartTimestamp = Stopwatch.GetTimestamp(); + + [Conditional("EVENT_LOOP_PROFILING")] + public static void PhaseEnd(LoopPhase phase) => + _current.Phases[(int)phase] += (Stopwatch.GetTimestamp() - _phaseStartTimestamp) * _msPerTick; + + [Conditional("EVENT_LOOP_PROFILING")] + public static void SleepEnd(int requestedMs, long elapsedMs) + { + _current.Sleeps++; + _current.SleepMs += elapsedMs; + + var overshoot = elapsedMs - requestedMs; + if (overshoot > _current.SleepOvershootMaxMs) + { + _current.SleepOvershootMaxMs = overshoot; + } + + if (overshoot >= Timer.TickRate) + { + _current.LateWakes++; + } + } + + [Conditional("EVENT_LOOP_PROFILING")] + public static void WheelSlice(long deltaSinceTurn) + { + var lag = deltaSinceTurn - Timer.TickRate; + if (lag > _current.WheelLagMaxMs) + { + _current.WheelLagMaxMs = lag; + } + } + + // Cross-thread; approximate counts are fine for diagnosis, so no interlocked. + [Conditional("EVENT_LOOP_PROFILING")] + public static void WakeSignal(bool elided) + { + if (elided) + { + _current.WakesElided++; + } + else + { + _current.WakesIssued++; + } + } +} diff --git a/Projects/Server/EventLoopTasks.cs b/Projects/Server/EventLoopTasks.cs index 88ae35b49..c3ff2c10c 100644 --- a/Projects/Server/EventLoopTasks.cs +++ b/Projects/Server/EventLoopTasks.cs @@ -42,10 +42,47 @@ public sealed class EventLoopContext : SynchronizationContext public override SynchronizationContext CreateCopy() => new EventLoopContext(); - public void Post(Action d, Priority priority = Priority.Normal) => - (priority == Priority.High ? _priorityQueue : _queue).Enqueue(d); + /// + /// True when no callbacks are waiting to run. + /// + /// + /// drains at most _maxPerFrame callbacks, so work can + /// legitimately be left over. The event loop checks this before sleeping so a backlog keeps + /// it running instead. + /// + public bool IsEmpty => _queue.IsEmpty && _priorityQueue.IsEmpty; - public override void Post(SendOrPostCallback d, object state) => _queue.Enqueue(() => d(state)); + public void Post(Action d, Priority priority = Priority.Normal) + { + (priority == Priority.High ? _priorityQueue : _queue).Enqueue(d); + WakeEventLoop(); + } + + public override void Post(SendOrPostCallback d, object state) + { + _queue.Enqueue(() => d(state)); + WakeEventLoop(); + } + + /// + /// Nudges the game loop in case it is asleep: the loop blocks on network I/O, which a queue + /// push alone does not signal. + /// + private void WakeEventLoop() + { + // A post from the loop thread cannot need a wake -- the loop is executing this very call + // -- and the signal is a syscall on every backend. + if (Thread.CurrentThread == _mainThread) + { + EventLoopProfiler.WakeSignal(elided: true); + return; + } + + EventLoopProfiler.WakeSignal(elided: false); + + // Safe before networking is configured and after teardown; NetState.Wake does nothing. + Network.NetState.Wake(); + } public override void Send(SendOrPostCallback d, object state) { @@ -63,6 +100,8 @@ public sealed class EventLoopContext : SynchronizationContext evt.Set(); }); + WakeEventLoop(); + evt.WaitOne(); } diff --git a/Projects/Server/Events/AccountLoginEvent.cs b/Projects/Server/Events/AccountLoginEvent.cs index 5c81af000..cf1c0bd1a 100644 --- a/Projects/Server/Events/AccountLoginEvent.cs +++ b/Projects/Server/Events/AccountLoginEvent.cs @@ -37,6 +37,13 @@ public class AccountLoginEventArgs public bool Accepted { get; set; } public ALRReason RejectReason { get; set; } + + /// + /// No verdict yet: a subscriber moved the password check off the game loop and replies itself + /// once it lands. The packet handler must send neither accept nor reject while this is set, or + /// the client gets two answers to one login. + /// + public bool Deferred { get; set; } } public static partial class EventSink diff --git a/Projects/Server/FeatureFlags.cs b/Projects/Server/FeatureFlags.cs index 99d6f9226..a70b4e452 100644 --- a/Projects/Server/FeatureFlags.cs +++ b/Projects/Server/FeatureFlags.cs @@ -10,4 +10,5 @@ public static class ServerFeatureFlags public static bool PvPCombat { get; set; } = true; public static bool BankAccess { get; set; } = true; public static bool SpeedhackDetection { get; set; } + public static bool InsuranceEnabled { get; set; } } diff --git a/Projects/Server/Items/Container.cs b/Projects/Server/Items/Container.cs index f139da24b..06c676b25 100644 --- a/Projects/Server/Items/Container.cs +++ b/Projects/Server/Items/Container.cs @@ -44,10 +44,10 @@ public partial class Container : Item internal int _version; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeLiftOverride))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _liftOverride; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLiftOverride() => _liftOverride; public Container(int itemID) : base(itemID) @@ -84,6 +84,7 @@ public partial class Container : Item [EncodedInt] [SerializableProperty(0)] + [SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { @@ -96,14 +97,13 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; - [SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; [EncodedInt] [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeGumpId), nameof(GumpIDDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int GumpID { @@ -115,14 +115,13 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeGumpId() => _gumpID != -1; - [SerializableFieldDefault(1)] private int GumpIDDefaultValue() => -1; [EncodedInt] [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializeDropSound), nameof(DropSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DropSound { @@ -134,10 +133,8 @@ public partial class Container : Item } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDropSound() => _dropSound != -1; - [SerializableFieldDefault(2)] private int DropSoundDefaultValue() => -1; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/Server/Items/DecayScheduler.cs b/Projects/Server/Items/DecayScheduler.cs index 694d9b6b2..70dc1ef27 100644 --- a/Projects/Server/Items/DecayScheduler.cs +++ b/Projects/Server/Items/DecayScheduler.cs @@ -311,7 +311,7 @@ public class DecayScheduler : Timer if (timeUntilDecay > _bucketInterval) { - // Item was moved (SetLastMoved called) - re-bucket or move to overflow + // Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow if (timeUntilDecay > _totalBucketSpan) { // Extended beyond total span - move to overflow @@ -429,7 +429,7 @@ public class DecayScheduler : Timer { // Refused by the region. Restart the clock rather than dropping the item, which has // already left the queue; re-registering as-is would spin on a due time in the past. - item.SetLastMoved(); + item.RestartDecay(); } } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index a3e546bc2..13f249f2f 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -14,6 +14,7 @@ *************************************************************************/ using System; +using System.Diagnostics; using System.Collections.Generic; using System.Reflection; using System.Runtime.CompilerServices; @@ -334,7 +335,25 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert [CommandProperty(AccessLevel.GameMaster)] public virtual bool Decays => Movable && Visible && Spawner == null; - public DateTime LastMoved { get; set; } + private DateTime _lastMoved; + + public DateTime LastMoved + { + get => _lastMoved; + set + { + _lastMoved = value; + + // A move at or past the reset stamp supersedes it; drop it so the CompactInfo can collapse. + var info = LookupCompactInfo(); + + if (info != null && info.m_DecayReset != default && info.m_DecayReset <= value) + { + info.m_DecayReset = default; + VerifyCompactInfo(); + } + } + } [CommandProperty(AccessLevel.GameMaster)] public bool Stackable @@ -372,7 +391,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Delta(ItemDelta.Update); - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -388,7 +407,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert SetFlag(ImplFlag.Movable, value); Delta(ItemDelta.Update); - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -748,6 +767,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public static bool ScissorCopyLootType { get; set; } + /// + /// True when the item was produced by the crafting system rather than bought or looted. + /// + [CommandProperty(AccessLevel.GameMaster)] + public bool PlayerConstructed { get; set; } + [CommandProperty(AccessLevel.GameMaster)] public bool QuestItem { @@ -798,7 +823,22 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual int HuedItemID => m_ItemID; - public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this)); + public ObjectPropertyList PropertyList + { + get + { + if (m_PropertyList == null) + { + // Publish the list before building it so a nested InvalidateProperties can see the + // build in progress and defer instead of recursing into a second throwaway list. + var list = new ObjectPropertyList(this); + m_PropertyList = list; + InitializePropertyList(list); + } + + return m_PropertyList; + } + } /// /// Overridable. Fills an with everything applicable. By default, this invokes @@ -823,7 +863,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual void Serialize(IGenericWriter writer) { - writer.Write(9); // version + writer.Write(11); // version var flags = SaveFlag.None; @@ -933,6 +973,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { flags |= SaveFlag.SavedFlags; } + + if (info.m_DecayReset > LastMoved) + { + flags |= SaveFlag.DecayReset; + } } if (info == null || info.m_Weight < 0) @@ -963,16 +1008,21 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert flags |= SaveFlag.ImplFlags; } + if (PlayerConstructed) + { + flags |= SaveFlag.PlayerConstructed; + } + writer.Write((int)flags); - /* begin last moved time optimization */ - var ticks = LastMoved.Ticks; - var now = Core.Now.Ticks; + // Anchored: shifted by downtime at load, so time-since-moved is preserved and the + // bytes are stable across saves while the item does not move. + writer.WriteAnchoredTime(LastMoved); - var minutes = new TimeSpan(now - ticks).TotalMinutes; - - writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); - /* end */ + if (GetSaveFlag(flags, SaveFlag.DecayReset)) + { + writer.WriteAnchoredTime(info.m_DecayReset); + } if (GetSaveFlag(flags, SaveFlag.Direction)) { @@ -1291,6 +1341,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert OnMapChange(); + if (m_Parent == null) + { + // A map change is a move; nothing else updates decay registration for a raw Map change. + SetLastMoved(); + } + if (old == null || old == Map.Internal) { InvalidateProperties(); @@ -1521,7 +1577,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (oldValue != value) { - UpdateDecayRegistration(); + RestartDecay(); } } } @@ -1715,6 +1771,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert || info.m_HeldBy != null || info.m_BlessedFor != null || info.m_Spawner != null + || info.m_DecayReset != default || info.m_TempFlags != 0 || info.m_SavedFlags != 0 || info.m_Weight >= 0; @@ -1884,7 +1941,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { list.Add(1049643); // cursed } - else if (Insured) + else if (ServerFeatureFlags.InsuranceEnabled && Insured) { list.Add(1061682); // insured } @@ -2299,7 +2356,64 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public virtual bool OnDecay() => CanDecay() && Region.Find(Location, Map).OnDecay(this); - public DateTime ScheduledDecayTime => LastMoved + DecayTime; + public DateTime ScheduledDecayTime + { + get + { + var reset = DecayResetTime; + var lastMoved = LastMoved; + + return (reset > lastMoved ? reset : lastMoved) + DecayTime; + } + } + + /// + /// When decay eligibility was last restored without the item moving, e.g. a GM unfreezing it. + /// The decay countdown runs from the later of this and . + /// + public DateTime DecayResetTime + { + get => LookupCompactInfo()?.m_DecayReset ?? default; + private set + { + if (value == default) + { + var info = LookupCompactInfo(); + + if (info != null && info.m_DecayReset != default) + { + info.m_DecayReset = default; + VerifyCompactInfo(); + } + } + else + { + AcquireCompactInfo().m_DecayReset = value; + } + } + } + + /// + /// Restarts the decay countdown without touching : call when decay + /// eligibility changes state (Movable/Visible/Spawner) or a region refuses a decay, where a + /// stale would otherwise decay the item on the next tick. + /// Stamps only when that extends the current deadline, then + /// updates the scheduler registration. + /// + public void RestartDecay() + { + if (CanDecay()) + { + var now = Core.Now; + + if (ScheduledDecayTime < now + DecayTime) + { + DecayResetTime = now; + } + } + + UpdateDecayRegistration(); + } public void UpdateDecayRegistration() { @@ -2309,6 +2423,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { DecayScheduler.Register(this); } + else + { + // No countdown to anchor while ineligible; drop the stamp so the CompactInfo + // can collapse. Re-eligibility always re-anchors. + DecayResetTime = default; + } } public void SetLastMoved() @@ -2341,6 +2461,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } Amount += dropped.Amount; + if (PlayerConstructed != dropped.PlayerConstructed) + { + PlayerConstructed = false; + } + dropped.Delete(); if (playSound && from != null) @@ -2429,9 +2554,19 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert private ObjectPropertyList InitializePropertyList(ObjectPropertyList list) { - GetProperties(list); - AppendChildProperties(list); - list.Terminate(); + list.IsBuilding = true; + + try + { + GetProperties(list); + AppendChildProperties(list); + list.Terminate(); + } + finally + { + list.IsBuilding = false; + } + return list; } @@ -2448,6 +2583,26 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert return; } + // Always a bug in the property getter, and there is no correct recovery: refuse rather than + // hide it. RELEASE keeps a possibly stale tooltip, DEBUG throws. + // See dev-docs/property-lists.md "Never Invalidate From Inside GetProperties". + if (m_PropertyList?.IsBuilding == true) + { + logger.Error( + "{Entity} called InvalidateProperties() while its property list was being built. Remove the side effect from the property getter, or defer it with Timer.DelayCall.\n{StackTrace}", + this, + new StackTrace() + ); + +#if DEBUG + throw new InvalidOperationException( + $"{this} invalidated its property list from inside GetProperties. Remove the side effect from the property getter." + ); +#else + return; +#endif + } + if (m_Map != null && m_Map != Map.Internal && !World.Loading) { int? oldHash; @@ -2611,6 +2766,8 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert switch (version) { + case 11: + case 10: case 9: case 8: case 7: @@ -2618,7 +2775,11 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert { var flags = (SaveFlag)reader.ReadInt(); - if (version < 7) + if (version >= 11) + { + LastMoved = reader.ReadAnchoredTime(); + } + else if (version < 7) { LastMoved = reader.ReadDeltaTime(); } @@ -2636,6 +2797,18 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } } + if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) + { + var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + + // Pre-v11 LastMoved was stored at whole-minute precision; keep the + // stamp only while it still extends the deadline. + if (reset > LastMoved) + { + DecayResetTime = reset; + } + } + if (GetSaveFlag(flags, SaveFlag.Direction)) { m_Direction = (Direction)reader.ReadByte(); @@ -2808,6 +2981,8 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert AcquireCompactInfo().m_SavedFlags = reader.ReadEncodedInt(); } + PlayerConstructed = GetSaveFlag(flags, SaveFlag.PlayerConstructed); + if (m_Map != null && m_Parent == null) { m_Map.OnEnter(this); @@ -3279,6 +3454,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert m_DeltaFlags &= ~flags; } + /// + /// True when deltas remain queued after a pass, which is + /// bounded by the count it saw on entry. The event loop consults this before sleeping. + /// + public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0; + public static void ProcessDeltaQueue() { var limit = m_DeltaQueue.Count; @@ -3385,7 +3566,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert for (var i = 0; i < props.Length; i++) { var p = props[i]; - if (p.GetCustomAttribute(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite) + if (p.GetCustomAttribute(true) != null || !p.CanRead || !p.CanWrite) { continue; } @@ -4216,12 +4397,12 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert } public virtual bool CheckBlessed(Mobile m) => - m_LootType == LootType.Blessed || Mobile.InsuranceEnabled && Insured || m != null && m == BlessedFor; + m_LootType == LootType.Blessed || ServerFeatureFlags.InsuranceEnabled && Insured || m != null && m == BlessedFor; public virtual bool CheckNewbied() => m_LootType == LootType.Newbied; public virtual bool IsStandardLoot() => - (!Mobile.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular; + (!ServerFeatureFlags.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular; public override string ToString() => $"{Serial} \"{GetType().Name}\""; @@ -4291,6 +4472,8 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert public ISpawner m_Spawner; + public DateTime m_DecayReset; + public int m_TempFlags; public double m_Weight = -1; @@ -4328,6 +4511,8 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert HeldBy = 0x00800000, IntWeight = 0x01000000, SavedFlags = 0x02000000, - NullWeight = 0x04000000 + NullWeight = 0x04000000, + PlayerConstructed = 0x08000000, + DecayReset = 0x10000000 } } diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index f89fd3084..608256651 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -1,4 +1,4 @@ -/************************************************************************* +/************************************************************************* * ModernUO * * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * @@ -30,6 +30,7 @@ using Server.Compression; using Server.Json; using Server.Logging; using Server.Network; +using Server.Network.Bans; using Server.Text; namespace Server; @@ -38,10 +39,181 @@ public static class Core { private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core)); - private static bool _performProcessKill; + // Written off-loop (Kill, RequestSnapshot); volatile because the loop blocks between reads. + private static volatile bool _performProcessKill; private static bool _restartOnKill; - private static bool _performSnapshot; + private static volatile bool _performSnapshot; private static string _snapshotPath; + + // A backstop, not a latency control: the wheel's tick rate already bounds the sleep. + // Measured across 1/2/4/8ms; 2 is optimal. + private static int _eventLoopIdleWaitMs = 2; + + /// + /// Longest the loop will block while idle, in milliseconds. 0 spins instead; the backoff + /// does the same temporarily when the host keeps returning waits late. + /// + public static int EventLoopIdleWaitMs => _eventLoopIdleWaitMs; + + /// + /// True when idle sleeping was disabled at startup because the host cannot honor short + /// waits, overriding whatever server.eventLoopIdleWaitMs was configured to. + /// + public static bool IdleSleepUnsupported { get; private set; } + + /// + /// Whether idle sleeping is currently suspended because the host returned waits late. + /// + /// + /// Compared by subtraction, never directly: tick counts can start enormous and wrap. + /// See dev-docs/tick-counts.md. + /// + public static bool IdleSleepSuspended => _tickCount - _idleSleepSuspendedUntil < 0; + + private const long HealthSampleIntervalMs = 1000; + + // Doubling: a fixed suspension oscillates forever on a persistently bad host, while doubling + // converges on "stop sleeping" yet still recovers from a transient. + private const long BackoffBaseMs = 5000; + private const long BackoffMaxMs = 120_000; + private const int BackoffMaxShift = 5; + + // Clean streak that clears the escalation. + private const long BackoffResetAfterCleanMs = 60_000; + + // Below this a backoff is still recoverable and not actionable, so it only logs at Debug. + private const int WarnAfterConsecutiveBackoffs = 3; + + // A sleep is bounded by the next wheel turn, so only a wait returning late can cost a deadline. + // Measured per sleep, which is why server work (saves, heavy commands) cannot trip the backoff. + private static int _lateWakes; + + // Denominator for the late-wake rate. + private static int _sleepAttempts; + + private static long _nextHealthSample; + private static long _idleSleepSuspendedUntil; + private static int _lateWakeThreshold = 1; + private static int _lateWakePercent = 10; + private static long _idleSleepBackoffs; + private static int _consecutiveBadSamples; + private static int _consecutiveBackoffs; + private static long _currentBackoffMs = BackoffBaseMs; + private static long _lastBackoffAt; + private static bool _loggedBackoffCeiling; + + /// + /// Once a second, suspends idle sleeping (with escalating duration) if the host keeps + /// returning idle waits a full tick or more late. + /// + private static void CheckSchedulerHealth() + { + if (_tickCount - _nextHealthSample < 0) + { + return; + } + + _nextHealthSample = _tickCount + HealthSampleIntervalMs; + + var late = _lateWakes; + var sleeps = _sleepAttempts; + _lateWakes = 0; + _sleepAttempts = 0; + + // A clean streak resets the escalation and re-arms the ceiling Error. Gated on the count + // rather than a "_lastBackoffAt > 0" sentinel because tick counts are not guaranteed positive. + if (_consecutiveBackoffs > 0 && _tickCount - _lastBackoffAt > BackoffResetAfterCleanMs) + { + if (_consecutiveBackoffs >= WarnAfterConsecutiveBackoffs) + { + logger.Information( + "This host has returned idle waits on time for {Duration}ms; idle sleeping is back to normal", + BackoffResetAfterCleanMs + ); + } + + _consecutiveBackoffs = 0; + _loggedBackoffCeiling = false; + } + + if (late <= _lateWakeThreshold) + { + _consecutiveBadSamples = 0; + return; + } + + // Lateness is a rate: an idle loop sleeps hundreds of times a second, so a few outliers are + // normal, while a host that cannot schedule the process returns most of its waits late. The + // threshold above is the floor for windows with too few sleeps for a proportion to mean anything. + if (late * 100 < sleeps * _lateWakePercent) + { + _consecutiveBadSamples = 0; + return; + } + + // Require persistence: any host can drop one sample to unrelated load, but an oversubscribed + // one stays bad. + if (++_consecutiveBadSamples < 2) + { + return; + } + + if (_eventLoopIdleWaitMs <= 0) + { + return; + } + + _currentBackoffMs = Math.Min(BackoffBaseMs << Math.Min(_consecutiveBackoffs, BackoffMaxShift), BackoffMaxMs); + _consecutiveBackoffs++; + _lastBackoffAt = _tickCount; + _idleSleepSuspendedUntil = _tickCount + _currentBackoffMs; + _idleSleepBackoffs++; + + if (_currentBackoffMs >= BackoffMaxMs) + { + // Escalation has run out of room; say so once. + if (!_loggedBackoffCeiling) + { + _loggedBackoffCeiling = true; + logger.Error( + "This host keeps returning idle waits late and sleeping has backed off {Count} times. " + + "The process is not being scheduled promptly, which is typical of shared or burstable vCPUs. " + + "Set server.eventLoopIdleWaitMs to 0 to disable sleeping permanently and trade a full core for latency.", + _idleSleepBackoffs + ); + } + + return; + } + + // Each backoff doubles the suspension, so every line is a distinct escalation step and + // needs no further rate limiting. + if (_consecutiveBackoffs < WarnAfterConsecutiveBackoffs) + { + logger.Debug( + "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} of {Sleeps} time(s) " + + "in the last second; idle sleeping suspended for {Duration}ms", + _eventLoopIdleWaitMs, + Timer.TickRate, + late, + sleeps, + _currentBackoffMs + ); + + return; + } + + logger.Warning( + "This host returned a {Requested}ms idle wait at least {TickRate}ms late {Count} of {Sleeps} time(s) in " + + "the last second, for the {Backoffs}th time running; idle sleeping suspended for {Duration}ms", + _eventLoopIdleWaitMs, + Timer.TickRate, + late, + sleeps, + _consecutiveBackoffs, + _currentBackoffMs + ); + } private static bool _crashed; private static string _baseDirectory; @@ -110,14 +282,6 @@ public static class Core public static long Uptime => TickCount - _firstTick; - private static double _currentCPS; - private static double _averageCPS; - private static bool _cpsInitialized; - - public static double CyclesPerSecond => _currentCPS; - - public static double AverageCPS => _averageCPS; - public static string BaseDirectory { get @@ -234,6 +398,10 @@ public static class Core { _restartOnKill = restart; _performProcessKill = true; + + // Callers are usually off-loop (console input, signal handlers); wake so the request + // is noticed now rather than whenever the loop next surfaces. + NetState.Wake(); } public static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) @@ -260,7 +428,7 @@ public static class Core // ignored } - if (!close && !Core.Headless) + if (!close && !Headless) { Console.WriteLine("This exception is fatal, press return to exit"); ConsoleInputHandler.ReadLine(); @@ -342,6 +510,8 @@ public static class Core World.ExitSerializationThreads(); PingServer.Shutdown(); NetState.Shutdown(); + BanChannel.Stop(); + ConnectionFilters.Stop(); if (!_crashed) { @@ -421,6 +591,45 @@ public static class Core ServerConfiguration.Load(); + // 0 disables idle sleeping entirely (full-core spin, zero scheduling overhead). + var idleWaitMs = ServerConfiguration.GetSetting("server.eventLoopIdleWaitMs", 2); + if (idleWaitMs < 0) + { + logger.Warning( + "server.eventLoopIdleWaitMs {Value} is negative; using 0 (idle sleeping disabled)", + idleWaitMs + ); + } + + _eventLoopIdleWaitMs = Math.Max(0, idleWaitMs); + + // Floor for the backoff: idle waits per second the host may return a full tick late before + // the rate test below applies at all. Set very high to disable the backoff. + var lateWakeThreshold = ServerConfiguration.GetSetting("server.lateWakeThreshold", 1); + if (lateWakeThreshold < 0) + { + logger.Warning( + "server.lateWakeThreshold {Value} is negative; using 0", + lateWakeThreshold + ); + } + + _lateWakeThreshold = Math.Max(0, lateWakeThreshold); + + // Share of a second's idle waits that must return late before the backoff trips. 0 leaves + // the threshold above in sole charge. + var lateWakePercent = ServerConfiguration.GetSetting("server.lateWakePercent", 10); + if (lateWakePercent is < 0 or > 100) + { + logger.Warning( + "server.lateWakePercent {Value} is outside 0-100; using {Clamped}", + lateWakePercent, + Math.Clamp(lateWakePercent, 0, 100) + ); + } + + _lateWakePercent = Math.Clamp(lateWakePercent, 0, 100); + var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration); // Load UOContent.dll @@ -437,10 +646,8 @@ public static class Core AssemblyHandler.LoadAssemblies(assemblyFiles); - // First-boot interactive setup. Runs after assemblies are loaded (so content can - // register prompts) but before any Serilog output, so console prompts are not - // interleaved with the async console sink. Handlers self-gate on first-boot state - // (e.g. "is my setting already present?"). + // First-boot interactive setup. After assemblies load so content can register prompts, + // before any Serilog output so prompts are not interleaved with the async console sink. AssemblyHandler.Invoke("ConfigurePrompts"); logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription); @@ -450,6 +657,11 @@ public static class Core _now = DateTime.UtcNow; _firstTick = _tickCount = GetTimestamp(); + // Seed from a real tick: tick counts need not start near zero, so a zero-initialized + // deadline compares wrong. See dev-docs/tick-counts.md. + _nextHealthSample = _tickCount + HealthSampleIntervalMs; + _idleSleepSuspendedUntil = _tickCount; + Timer.Init(_tickCount); AssemblyHandler.Invoke("Configure"); @@ -461,44 +673,76 @@ public static class Core AssemblyHandler.Invoke("Initialize"); + BanChannel.Start(ClosingTokenSource.Token); + ConnectionFilters.Start(ClosingTokenSource.Token); NetState.Start(); PingServer.Start(); EventSink.InvokeServerStarted(); + + // Without a high-resolution wait a 2ms request quantises to 15.625ms and the loop runs a + // tick behind. Only fires when the high-res timer and the timeBeginPeriod fallback both failed. + if (_eventLoopIdleWaitMs > 0 && NetState.Ring?.SupportsHighResolutionWait == false) + { + logger.Error( + "This host cannot honor short waits (no high-resolution timer, and raising the system timer " + + "resolution failed). Idle sleeping is disabled. The loop will spin instead, using a full core." + ); + + IdleSleepUnsupported = true; + _eventLoopIdleWaitMs = 0; + } + RunEventLoop(); } + /// + /// True when every queue the loop drains is empty, so sleeping cannot strand pending work. + /// The drains are bounded, so leftovers are normal and must keep the loop awake. + /// + private static bool IsIdle() => + !Mobile.HasQueuedDeltas && !Item.HasQueuedDeltas && LoopContext.IsEmpty && NetState.IsIdle; + public static void RunEventLoop() { try { - var lastRaw = Stopwatch.GetTimestamp(); - const int interval = 100; - double frequency = Stopwatch.Frequency * interval; - const double alpha = 2.0 / 129; // EMA smoothing (≈128-sample window) - - var sample = 0; - while (!Closing) { _tickCount = GetTimestamp(); _now = DateTime.UtcNow; + EventLoopProfiler.IterationStart(_tickCount); + + EventLoopProfiler.PhaseStart(LoopPhase.MobileDeltas); Mobile.ProcessDeltaQueue(); + EventLoopProfiler.PhaseEnd(LoopPhase.MobileDeltas); + + EventLoopProfiler.PhaseStart(LoopPhase.ItemDeltas); Item.ProcessDeltaQueue(); + EventLoopProfiler.PhaseEnd(LoopPhase.ItemDeltas); + + EventLoopProfiler.PhaseStart(LoopPhase.TimerSlice); Timer.Slice(_tickCount); + EventLoopProfiler.PhaseEnd(LoopPhase.TimerSlice); // Handle networking + EventLoopProfiler.PhaseStart(LoopPhase.NetworkSlice); NetState.Slice(); + EventLoopProfiler.PhaseEnd(LoopPhase.NetworkSlice); // Execute captured post-await methods (like Timer.Pause) + EventLoopProfiler.PhaseStart(LoopPhase.LoopTasks); LoopContext.ExecuteTasks(); + EventLoopProfiler.PhaseEnd(LoopPhase.LoopTasks); Timer.CheckTimerPool(); // Check for pool depletion so we can async refill it. if (_performSnapshot) { + EventLoopProfiler.PhaseStart(LoopPhase.WorldSnapshot); // Return value is the offset that can be used to fix timers that should drift World.Snapshot(_snapshotPath); + EventLoopProfiler.PhaseEnd(LoopPhase.WorldSnapshot); _performSnapshot = false; } @@ -508,29 +752,35 @@ public static class Core break; } - if (sample++ == interval) + CheckSchedulerHealth(); + + if (_eventLoopIdleWaitMs > 0 && _tickCount - _idleSleepSuspendedUntil >= 0 && IsIdle()) { - sample = 0; - var nowRaw = Stopwatch.GetTimestamp(); - - _currentCPS = frequency / (nowRaw - lastRaw); - - if (!_cpsInitialized) + // Re-read the clock: a stale timestamp overstates the time to the next tick + // and sleeps straight past it. + var start = GetTimestamp(); + var due = Timer.MillisecondsUntilNextTick(start); + if (due > 0) { - _averageCPS = _currentCPS; - _cpsInitialized = true; - } - else - { - _averageCPS += alpha * (_currentCPS - _averageCPS); - } + var requested = (int)Math.Min(due, _eventLoopIdleWaitMs); - lastRaw = nowRaw; + // The GC prefers to collect during idle sleeps, so its pauses land here by + // design and are not the host's fault. Gen1 and above (what + // CollectionCount(1) counts) are the only pauses long enough to reach a tick. + var collections = GC.CollectionCount(1); - var sleepMs = (int)Timer.MillisecondsUntilNextTick(_tickCount); - if (sleepMs >= 2) - { - NetState.WaitForCompletion(sleepMs - 1); + NetState.WaitForCompletion(requested); + + var elapsed = GetTimestamp() - start; + EventLoopProfiler.SleepEnd(requested, elapsed); + _sleepAttempts++; + + // The second collection read sits behind the overshoot test, so the common + // path reads the counter once, not twice. + if (elapsed - requested >= Timer.TickRate && GC.CollectionCount(1) == collections) + { + _lateWakes++; + } } } } @@ -548,6 +798,9 @@ public static class Core { _snapshotPath = snapshotPath; _performSnapshot = true; + + // Save requests arrive off-loop; wake so the snapshot starts now. + NetState.Wake(); } public static void VerifySerialization() diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 3e19548c6..fb028b9d7 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -26,6 +26,7 @@ using Server.Network; using Server.Prompts; using Server.Targeting; using System; +using System.Diagnostics; using System.Collections.Generic; using System.Runtime.CompilerServices; using Server.Buffers; @@ -953,8 +954,6 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public static TimeSpan AutoManifestTimeout { get; set; } = TimeSpan.FromSeconds(5.0); - public static bool InsuranceEnabled { get; set; } - public static int ActionDelay { get; set; } = 500; public static VisibleDamageType VisibleDamageType { get; set; } @@ -2293,7 +2292,22 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public int CompareTo(Mobile other) => other == null ? -1 : Serial.CompareTo(other.Serial); public virtual int HuedItemID => m_Female ? 0x2107 : 0x2106; - public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this)); + public ObjectPropertyList PropertyList + { + get + { + if (m_PropertyList == null) + { + // Publish the list before building it so a nested InvalidateProperties can see the + // build in progress and defer instead of recursing into a second throwaway list. + var list = new ObjectPropertyList(this); + m_PropertyList = list; + InitializePropertyList(list); + } + + return m_PropertyList; + } + } public virtual void GetProperties(IPropertyList list) { @@ -2310,11 +2324,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro public virtual void Serialize(IGenericWriter writer) { - writer.Write(37); // version + writer.Write(38); // version - writer.WriteDeltaTime(LastStrGain); - writer.WriteDeltaTime(LastIntGain); - writer.WriteDeltaTime(LastDexGain); + writer.WriteAnchoredTime(LastStrGain); + writer.WriteAnchoredTime(LastIntGain); + writer.WriteAnchoredTime(LastDexGain); byte hairflag = 0x00; @@ -5234,8 +5248,15 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro item.Name = oldItem.Name; item.Weight = oldItem.Weight; + item.PlayerConstructed = oldItem.PlayerConstructed; item.Amount = oldAmount - amount; - item.Map = oldItem.Map; + + // A parented remainder gets its map from AddItem (parent first, then map), keeping the + // split off the decay scheduler; a ground remainder is placed and enrolled here. + if (oldItem.Parent == null) + { + item.Map = oldItem.Map; + } oldItem.OnAfterDuped(item); @@ -6129,6 +6150,7 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro switch (version) { + case 38: // Stat-gain stamps moved from delta time to anchored time case 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object) case 36: // Moved virtues to VirtueSystem case 35: // Moved short term murders to PlayerMurderSystem @@ -6137,9 +6159,18 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro case 32: // Removed StuckMenu case 31: { - LastStrGain = reader.ReadDeltaTime(); - LastIntGain = reader.ReadDeltaTime(); - LastDexGain = reader.ReadDeltaTime(); + if (version >= 38) + { + LastStrGain = reader.ReadAnchoredTime(); + LastIntGain = reader.ReadAnchoredTime(); + LastDexGain = reader.ReadAnchoredTime(); + } + else + { + LastStrGain = reader.ReadDeltaTime(); + LastIntGain = reader.ReadDeltaTime(); + LastDexGain = reader.ReadDeltaTime(); + } goto case 30; } @@ -7227,8 +7258,18 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro private ObjectPropertyList InitializePropertyList(ObjectPropertyList list) { - GetProperties(list); - list.Terminate(); + list.IsBuilding = true; + + try + { + GetProperties(list); + list.Terminate(); + } + finally + { + list.IsBuilding = false; + } + return list; } @@ -7245,6 +7286,26 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro return; } + // Always a bug in the property getter, and there is no correct recovery: refuse rather than + // hide it. RELEASE keeps a possibly stale tooltip, DEBUG throws. + // See dev-docs/property-lists.md "Never Invalidate From Inside GetProperties". + if (m_PropertyList?.IsBuilding == true) + { + logger.Error( + "{Entity} called InvalidateProperties() while its property list was being built. Remove the side effect from the property getter, or defer it with Timer.DelayCall.\n{StackTrace}", + this, + new StackTrace() + ); + +#if DEBUG + throw new InvalidOperationException( + $"{this} invalidated its property list from inside GetProperties. Remove the side effect from the property getter." + ); +#else + return; +#endif + } + if (m_Map != null && m_Map != Map.Internal && !World.Loading) { int? oldHash; @@ -7790,6 +7851,12 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro } } + /// + /// True when deltas remain queued after a pass, which is + /// bounded by the count it saw on entry. The event loop consults this before sleeping. + /// + public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0; + public static void ProcessDeltaQueue() { var limit = m_DeltaQueue.Count; diff --git a/Projects/Server/Mobiles/Mods/ResistanceMod.cs b/Projects/Server/Mobiles/Mods/ResistanceMod.cs index bd569f20e..a5423e039 100644 --- a/Projects/Server/Mobiles/Mods/ResistanceMod.cs +++ b/Projects/Server/Mobiles/Mods/ResistanceMod.cs @@ -21,17 +21,15 @@ namespace Server; [SerializationGenerator(0)] public partial class ResistanceMod : MobileMod { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] private ResistanceType _type; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances(); - [SerializableField(1)] + [SerializableField(1, fieldChanged: nameof(OnOffsetChanged))] private int _offset; - [SerializableFieldChanged(1)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances(); diff --git a/Projects/Server/Mobiles/Mods/SkillMod.cs b/Projects/Server/Mobiles/Mods/SkillMod.cs index a78ec5ce6..cfe96c0c4 100644 --- a/Projects/Server/Mobiles/Mods/SkillMod.cs +++ b/Projects/Server/Mobiles/Mods/SkillMod.cs @@ -21,33 +21,29 @@ namespace Server; [SerializationGenerator(0)] public abstract partial class SkillMod : MobileMod { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))] private bool _obeyCap; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); + private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(1)] + [SerializableField(1, fieldChanged: nameof(OnSkillChanged))] private SkillName _skill; - [SerializableFieldChanged(1)] private void OnSkillChanged(SkillName oldValue, SkillName newValue) { Owner?.Skills[newValue]?.Update(); Owner?.Skills[oldValue]?.Update(); } - [SerializableField(2)] + [SerializableField(2, fieldChanged: nameof(OnRelativeChanged))] private bool _relative; - [SerializableFieldChanged(2)] private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); - [SerializableField(3)] + [SerializableField(3, fieldChanged: nameof(OnValueChanged))] private double _value; - [SerializableFieldChanged(3)] private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update(); public SkillMod(Mobile owner) : base(owner) diff --git a/Projects/Server/Network/Bans/BanChannel.cs b/Projects/Server/Network/Bans/BanChannel.cs new file mode 100644 index 000000000..b682158d0 --- /dev/null +++ b/Projects/Server/Network/Bans/BanChannel.cs @@ -0,0 +1,160 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanChannel.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Server.Logging; + +namespace Server.Network.Bans; + +/// +/// Coordinates the configured contribution sinks. Enforcement is NOT here — +/// the accept path asks . This channel only fans locally-decided bans out +/// to external systems (CrowdSec), which distribute them to OS-level bouncers. +/// +public static class BanChannel +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BanChannel)); + + private static IBanReporter[] _reporters = []; + + public static IReadOnlyList Reporters => _reporters; + + /// + /// Registers a contribution sink from content (inversion of control). Idempotent by + /// : a second registration of the same name is ignored. Configures the + /// reporter immediately so it is ready before . + /// + public static void Register(IBanReporter reporter) + { + if (reporter == null) + { + return; + } + + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + if (reporters[i].Name == reporter.Name) + { + return; + } + } + + reporter.Register(); + + var updated = new IBanReporter[_reporters.Length + 1]; + Array.Copy(_reporters, updated, _reporters.Length); + updated[^1] = reporter; + _reporters = updated; + + logger.Information("Ban channel registered reporter '{Name}'", reporter.Name); + } + + internal static void ConfigureForTesting(IBanReporter[] reporters) => _reporters = reporters ?? []; + + public static void Start(CancellationToken token) + { + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + var reporter = reporters[i]; + try + { + reporter.Start(token); + } + catch (Exception e) + { + // A broken contribution path must not crash boot — enforcement is local and unaffected. + logger.Error(e, "Ban reporter '{Name}' failed to start; continuing without it", reporter.Name); + } + } + } + + public static void Stop() + { + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + var reporter = reporters[i]; + try + { + reporter.Stop(); + } + catch (Exception e) + { + logger.Warning(e, "Ban reporter '{Name}' threw while stopping", reporter.Name); + } + } + } + + /// Fans a locally-decided ban out to every reporter. Non-blocking; never throws. + /// + /// Optional content-supplied exemption; true drops the contribution before any reporter sees it. This + /// withholds escalation only — the gate that reached the verdict has already acted. + /// + /// + /// An implementation that ignores reason would silently swallow manual bans. See + /// . + /// + public static Func IsExempt { get; set; } + + public static void Report(IPAddress ip, TimeSpan ttl, string reason) + { + var exempt = IsExempt; + if (exempt != null && exempt(ip, reason)) + { + logger.Debug("{Address} not contributed ('{Reason}'): exempt", ip, reason); + return; + } + + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + try + { + reporters[i].Report(ip, ttl, reason); + } + catch (Exception e) + { + logger.Warning(e, "Ban reporter '{Name}' threw during Report", reporters[i].Name); + } + } + } + + /// Fans a retraction (manual unban) out to every retract-capable reporter. + public static void Retract(IPAddress ip) + { + var reporters = _reporters; + for (var i = 0; i < reporters.Length; i++) + { + if (!reporters[i].CanRetract) + { + continue; + } + + try + { + reporters[i].Retract(ip); + } + catch (Exception e) + { + logger.Warning(e, "Ban reporter '{Name}' threw during Retract", reporters[i].Name); + } + } + } +} diff --git a/Projects/Server/Network/Bans/BanConfiguration.cs b/Projects/Server/Network/Bans/BanConfiguration.cs new file mode 100644 index 000000000..d678a6470 --- /dev/null +++ b/Projects/Server/Network/Bans/BanConfiguration.cs @@ -0,0 +1,98 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanConfiguration.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans; + +/// +/// Loads the from Configuration/bans.json (matching the per-feature +/// JSON config pattern used by AssistantConfiguration). Loaded once; a missing file writes a +/// local-only, fail-open template so operators have something to edit. +/// +public static class BanConfiguration +{ + private const string _path = "Configuration/bans.json"; + + private static bool _loaded; + + /// + /// Never null: the accept and reap paths read this per connection, including before + /// has run (a harness driving NetState.Slice directly), so it starts at the record's defaults. + /// + public static BanSettings Settings { get; private set; } = new(); + + public static void Configure() + { + // Idempotent; flagged rather than null-checked because Settings is non-null from the start. + if (_loaded) + { + return; + } + + _loaded = true; + + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new BanSettings + { + ReportRateLimitTrips = true, + AutoBanDuration = TimeSpan.FromHours(4) + }; + + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// Ban-channel policy: which reporters receive contributions, and how auto-detections are handled. +public record BanSettings +{ + /// Whether IP rate-limiter trips are contributed to reporters. They never enter the local firewall set. + [JsonPropertyName("reportRateLimitTrips")] + public bool ReportRateLimitTrips { get; set; } = true; + + /// Duration reported for an auto-detected (rate-limit) ban. + [JsonPropertyName("autoBanDuration")] + public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4); + + /// + /// Whether behavioural detections are contributed to reporters. Those connections are disconnected either + /// way; this only controls escalation. + /// + /// + /// Keyed on bytes-received, never elapsed time: a connection that sent something and ran out of time is + /// far more likely a slow link than an attack. See dev-docs/ip-bans-and-allowlists.md. + /// + [JsonPropertyName("reportBadConnects")] + public bool ReportBadConnects { get; set; } = true; + + [JsonPropertyName("badConnectDuration")] + public TimeSpan BadConnectDuration { get; set; } = TimeSpan.FromHours(4); +} diff --git a/Projects/Server/Network/Bans/BanReasons.cs b/Projects/Server/Network/Bans/BanReasons.cs new file mode 100644 index 000000000..16708f716 --- /dev/null +++ b/Projects/Server/Network/Bans/BanReasons.cs @@ -0,0 +1,48 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanReasons.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +namespace Server.Network.Bans; + +/// The reason slugs contributed through . Policy keys off these. +public static class BanReasons +{ + /// An operator banned this address explicitly. Never exempt, never auto-denied. + public const string Manual = "manual"; + + public const string RateLimit = "rate-limit"; + + /// Matched the reputation blocklist. Enforced by its own filter, not by behaviour. + public const string Blocklist = "blocklist"; + + /// Reaped without ever sending a byte. + public const string SilentConnect = "silent-connect"; + + /// Opened with a zero seed, which no real client sends. + public const string InvalidSeed = "invalid-seed"; + + /// Positively identified as another protocol entirely. See . + public const string ForeignProtocol = "foreign-protocol"; + + /// + /// Verdicts the shard reached by watching the connection. Only these may be exempted, and only these feed + /// the local denylist. + /// + /// + /// Opt-in rather than "everything except ", so a reason added later escalates normally + /// instead of silently inheriting an exemption. + /// + public static bool IsBehavioral(string reason) => + reason is RateLimit or SilentConnect or InvalidSeed or ForeignProtocol; +} diff --git a/Projects/Server/Network/Bans/IBanReporter.cs b/Projects/Server/Network/Bans/IBanReporter.cs new file mode 100644 index 000000000..190c6936e --- /dev/null +++ b/Projects/Server/Network/Bans/IBanReporter.cs @@ -0,0 +1,55 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: IBanReporter.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Net; +using System.Threading; + +namespace Server.Network.Bans; + +/// +/// A contribution sink behind . Reporters receive locally-decided bans +/// (manual admin bans, rate-limit trips, blocklist promotions) and forward them to an external system +/// (e.g. CrowdSec), which distributes them to OS-level bouncers. Reporters never answer the accept-path +/// membership query — that is an 's job. +/// +public interface IBanReporter +{ + /// Stable id for logging/config (e.g. crowdsec). + string Name { get; } + + /// Reads configuration. No network or file I/O here. + void Register(); + + /// Starts background delivery. The token is cancelled on shutdown. + void Start(CancellationToken token); + + /// Flushes and tears down background delivery. + void Stop(); + + /// + /// Enqueues a ban contribution. MUST be non-blocking and safe on the accept path: it may only + /// enqueue (bounded, drop-on-overflow) and never perform synchronous I/O. + /// + /// or negative = use the reporter's default duration. + /// Short slug (manual, rate-limit) used as the scenario suffix. + void Report(IPAddress address, TimeSpan ttl, string reason); + + /// True if this reporter can retract a previously-reported ban. + bool CanRetract { get; } + + /// Enqueues a retraction (e.g. a manual unban). No-op if unsupported. + void Retract(IPAddress address); +} diff --git a/Projects/Server/Network/ConnectionFilters.cs b/Projects/Server/Network/ConnectionFilters.cs new file mode 100644 index 000000000..c0bb0b2fb --- /dev/null +++ b/Projects/Server/Network/ConnectionFilters.cs @@ -0,0 +1,155 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ConnectionFilters.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Server.Logging; + +namespace Server.Network; + +/// +/// Registry of the gates the accept path consults, and their lifecycle. +/// Filters are registered during the Configure sweep and the backing store is a plain array, so +/// is an indexed loop over a field read — no enumerator, no closure, no +/// allocation. The whole accept path runs on the game loop, so no synchronization is needed. +/// +public static class ConnectionFilters +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConnectionFilters)); + + private static IConnectionFilter[] _filters = []; + + public static IReadOnlyList Filters => _filters; + + /// + /// Registers a gate (inversion of control, mirroring BanChannel.Register). Idempotent by + /// . Filters are consulted in registration order, so register + /// the cheapest and most selective first — core registers the firewall before content is swept. + /// + public static void Register(IConnectionFilter filter) + { + if (filter == null) + { + return; + } + + var filters = _filters; + for (var i = 0; i < filters.Length; i++) + { + if (filters[i].Name == filter.Name) + { + return; + } + } + + filter.Register(); + + var updated = new IConnectionFilter[_filters.Length + 1]; + Array.Copy(_filters, updated, _filters.Length); + updated[^1] = filter; + _filters = updated; + + logger.Information("Registered connection filter '{Name}'", filter.Name); + } + + /// + /// True when any filter denies the connection. Short-circuits on the first denial; + /// names it for logging. + /// + public static bool ShouldDeny(IPAddress address, out string deniedBy) + { + var filters = _filters; + for (var i = 0; i < filters.Length; i++) + { + // A faulty filter must not take down the accept loop for every connection. + try + { + if (filters[i].ShouldDeny(address)) + { + deniedBy = filters[i].Name; + return true; + } + } + catch (Exception e) + { + Disable(filters[i], e); + } + } + + deniedBy = null; + return false; + } + + /// + /// Drops a filter that threw on the accept path: one that faults once faults for every subsequent + /// connection, costing an exception and a log line per accept. Failing open is deliberate — a broken + /// filter must not be able to deny every connection either. + /// + private static void Disable(IConnectionFilter filter, Exception e) + { + logger.Error(e, "Connection filter '{Name}' threw on the accept path; unregistering it", filter.Name); + + var filters = _filters; + var updated = new List(filters.Length); + for (var i = 0; i < filters.Length; i++) + { + if (!ReferenceEquals(filters[i], filter)) + { + updated.Add(filters[i]); + } + } + + _filters = updated.ToArray(); + } + + public static void Start(CancellationToken token) + { + var filters = _filters; + for (var i = 0; i < filters.Length; i++) + { + var filter = filters[i]; + try + { + filter.Start(token); + } + catch (Exception e) + { + // A filter that cannot hydrate must not crash boot; it simply denies nothing. + logger.Error(e, "Connection filter '{Name}' failed to start; continuing without it", filter.Name); + } + } + } + + public static void Stop() + { + var filters = _filters; + for (var i = 0; i < filters.Length; i++) + { + var filter = filters[i]; + try + { + filter.Stop(); + } + catch (Exception e) + { + logger.Warning(e, "Connection filter '{Name}' threw while stopping", filter.Name); + } + } + } + + internal static void ResetForTesting() => _filters = []; +} diff --git a/Projects/Server/Network/Firewall/Firewall.cs b/Projects/Server/Network/Firewall/Firewall.cs deleted file mode 100644 index ffb778aa8..000000000 --- a/Projects/Server/Network/Firewall/Firewall.cs +++ /dev/null @@ -1,168 +0,0 @@ -/************************************************************************* - * ModernUO * - * Copyright 2019-2026 - ModernUO Development Team * - * Email: hi@modernuo.com * - * File: Firewall.cs * - * * - * This program is free software: you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation, either version 3 of the License, or * - * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * - *************************************************************************/ - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Net; -using System.Runtime.CompilerServices; -using System.Threading; - -namespace Server.Network; - -public static class Firewall -{ - [ThreadStatic] - private static InternalValidationEntry _validationEntry; - private static readonly ConcurrentDictionary _isBlockedCache = []; - private static readonly ReaderWriterLockSlim _firewallLock = new(LockRecursionPolicy.NoRecursion); - - private static int _firewallVersion; - private static readonly SortedSet _firewallSet = []; - - public static int FirewallSetCount => _firewallSet.Count; - - public static void ReadFirewallSet(Action> callback) - { - _firewallLock.EnterReadLock(); - try - { - callback(_firewallSet); - } - finally - { - _firewallLock.ExitReadLock(); - } - } - - internal static bool IsBlocked(IPAddress address) - { - if (_isBlockedCache.TryGetValue(address, out var blockVersion) && blockVersion == _firewallVersion) - { - return true; - } - - if (_validationEntry == null) - { - _validationEntry = new InternalValidationEntry(address); - } - else - { - _validationEntry.Address = address; - } - - if (CheckBlocked(_validationEntry)) - { - _isBlockedCache[address] = _firewallVersion; - return true; - } - - return false; - } - - private static bool CheckBlocked(IFirewallEntry validationEntry) - { - if (_firewallSet.Count == 0) - { - return false; - } - - _firewallLock.EnterReadLock(); - try - { - var min = _firewallSet.Min; - if (validationEntry.CompareTo(min) < 0) - { - return false; - } - - // Get all entries that are lower than our validation entry - var view = _firewallSet.GetViewBetween(min, validationEntry); - - // Loop backward since there shouldn't be any entries where the Min address is higher than ours - foreach (var firewallEntry in view.Reverse()) - { - if (firewallEntry.IsBlocked(validationEntry.MinIpAddress)) - { - return true; - } - } - - return view.Max?.IsBlocked(validationEntry.MinIpAddress) == true; - } - finally - { - _firewallLock.ExitReadLock(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool Add(IFirewallEntry firewallEntry) - { - _firewallLock.EnterWriteLock(); - try - { - if (_firewallSet.Add(firewallEntry)) - { - Interlocked.Increment(ref _firewallVersion); // Update version - return true; - } - return false; - } - finally - { - _firewallLock.ExitWriteLock(); - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool Remove(IFirewallEntry entry) - { - if (entry == null) - { - return false; - } - - _firewallLock.EnterWriteLock(); - try - { - if (_firewallSet.Remove(entry)) - { - Interlocked.Increment(ref _firewallVersion); // Update version - return true; - } - return false; - } - finally - { - _firewallLock.ExitWriteLock(); - } - } - - private class InternalValidationEntry : BaseFirewallEntry - { - private UInt128 _address; - - public IPAddress Address - { - set => _address = value.ToUInt128(); - } - - public override UInt128 MinIpAddress => _address; - public override UInt128 MaxIpAddress => _address; - - public InternalValidationEntry(IPAddress ipAddress) => Address = ipAddress; - } -} diff --git a/Projects/Server/Network/ForeignProtocol.cs b/Projects/Server/Network/ForeignProtocol.cs new file mode 100644 index 000000000..059a69d3f --- /dev/null +++ b/Projects/Server/Network/ForeignProtocol.cs @@ -0,0 +1,146 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ForeignProtocol.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; + +namespace Server.Network; + +public enum ForeignProtocolKind +{ + None, + Http, + Tls, + Ssh +} + +public enum ForeignProtocolMatch +{ + None, + + /// A prefix matched, but more bytes are needed to be sure. + Incomplete, + + Confirmed +} + +/// +/// Identifies traffic that is positively some OTHER protocol (HTTP, TLS, SSH), rather than deciding whether +/// a connection is a good Ultima Online client. +/// +/// +/// The direction matters. A legitimate client with encryption enabled when the shard expects none sends a +/// structurally correct connection whose payload is noise, because LoginEncryption.ClientDecrypt is a +/// byte-for-byte stream XOR: length survives, content does not. So "unreadable" cannot mean "hostile", while +/// "speaks HTTP" safely can. Nothing here assumes arrival framing; see +/// dev-docs/ip-bans-and-allowlists.md. +/// +public static class ForeignProtocol +{ + private const int RequiredBytes = 8; + private const int MaxTlsRecordLength = 16384; + + public static ForeignProtocolMatch Identify(ReadOnlySpan buffer, out ForeignProtocolKind kind) + { + kind = ForeignProtocolKind.None; + + // Too little to match a prefix; the caller's own short-read handling covers it. + if (buffer.Length < 4) + { + return ForeignProtocolMatch.None; + } + + var candidate = MatchPrefix(buffer); + if (candidate == ForeignProtocolKind.None) + { + return ForeignProtocolMatch.None; + } + + if (buffer.Length < RequiredBytes) + { + return ForeignProtocolMatch.Incomplete; + } + + if (!Confirm(buffer, candidate)) + { + return ForeignProtocolMatch.None; + } + + kind = candidate; + return ForeignProtocolMatch.Confirmed; + } + + private static ForeignProtocolKind MatchPrefix(ReadOnlySpan buffer) + { + // TLS handshake record: content type 0x16, major version 3, minor version 0..4 (SSL 3.0 - TLS 1.3). + if (buffer[0] == 0x16 && buffer[1] == 0x03 && buffer[2] <= 0x04) + { + return ForeignProtocolKind.Tls; + } + + if (StartsWith(buffer, "SSH-")) + { + return ForeignProtocolKind.Ssh; + } + + // HTTP request methods. Four bytes only selects a candidate; Confirm checks the request line. + if (StartsWith(buffer, "GET ") || StartsWith(buffer, "POST") || StartsWith(buffer, "HEAD") || + StartsWith(buffer, "PUT ") || StartsWith(buffer, "OPTI") || StartsWith(buffer, "DELE") || + StartsWith(buffer, "CONN") || StartsWith(buffer, "TRAC") || StartsWith(buffer, "PATC")) + { + return ForeignProtocolKind.Http; + } + + return ForeignProtocolKind.None; + } + + private static bool Confirm(ReadOnlySpan buffer, ForeignProtocolKind candidate) + { + if (candidate == ForeignProtocolKind.Tls) + { + // A seed can collide with the 0x16 0x03 0x0? prefix (that is just the address 22.3.x.x), so + // require a ClientHello inside a plausible record. + var recordLength = (buffer[3] << 8) | buffer[4]; + return buffer[5] == 0x01 && recordLength is >= 4 and <= MaxTlsRecordLength; + } + + // A UO client's fifth byte is a packet id (0x80, 0x91, 0xEF), none of them printable, so requiring + // the request line to continue in ASCII lets a seed that spells "GET " fall through. + for (var i = 4; i < buffer.Length && i < 16; i++) + { + if (!IsPrintableAscii(buffer[i])) + { + return false; + } + } + + return true; + } + + private static bool IsPrintableAscii(byte value) => + value is >= 0x20 and <= 0x7E or (byte)'\r' or (byte)'\n' or (byte)'\t'; + + private static bool StartsWith(ReadOnlySpan buffer, string ascii) + { + for (var i = 0; i < ascii.Length; i++) + { + if (buffer[i] != (byte)ascii[i]) + { + return false; + } + } + + return true; + } +} diff --git a/Projects/Server/Network/IConnectionFilter.cs b/Projects/Server/Network/IConnectionFilter.cs new file mode 100644 index 000000000..48e971c85 --- /dev/null +++ b/Projects/Server/Network/IConnectionFilter.cs @@ -0,0 +1,60 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: IConnectionFilter.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using System.Threading; + +namespace Server.Network; + +/// +/// A gate consulted for every inbound connection, before the socket is configured and before any +/// per-connection allocation. Implementations decide membership only — the accept path neither knows +/// nor cares where a filter's data comes from, so a filter may be a handful of admin-curated entries, +/// a millions-strong list hydrated from a file, or a query against something else entirely. Core owns +/// the question; content owns every answer (see Firewall and BlocklistFilter in UOContent). +/// +/// +/// +/// runs on the game loop once per accepted socket, which is the path that has +/// to survive a DDoS. Implementations MUST be allocation-free and O(log n) at worst, MUST NOT perform +/// I/O, and MUST NOT block. Anything expensive (parsing, reloading, reporting to an external service) +/// belongs off the loop or behind a bounded, non-blocking enqueue. +/// +/// +/// Side effects that a hit implies (contributing to BanChannel, promoting to an OS firewall, +/// suppressing duplicate reports) are the filter's own business, not the accept path's. This is why +/// returns a bare bool: the accept path asks one question and does one thing. +/// +/// +public interface IConnectionFilter +{ + /// Stable id for logging/config (e.g. firewall, blocklist). + string Name { get; } + + /// Reads configuration. Called by . No I/O beyond config. + void Register(); + + /// Starts any background hydration. The token is cancelled on shutdown. + void Start(CancellationToken token); + + /// Flushes and tears down. Called during shutdown. + void Stop(); + + /// + /// True to deny the connection. Must be allocation-free and non-blocking; see the remarks on + /// . + /// + bool ShouldDeny(IPAddress address); +} diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index 9bfd39673..f69f92076 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -19,6 +19,7 @@ using System.Linq; using System.Net; using System.Net.NetworkInformation; using System.Network; +using System.Numerics; namespace Server.Network; @@ -28,9 +29,10 @@ namespace Server.Network; public partial class NetState { // Buffer sizes - private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers - private const int SendBufferSize = 1024 * 256; // 256KB send buffers - private const int MaxConnections = 4096; // Max concurrent connections + private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers + private const int DefaultSendBufferSize = 1024 * 256; // 256KB send buffers + private const int MinSendBufferSize = 1024 * 64; // Platform allocation granularity + private const int MaxConnections = 4096; // Max concurrent connections private static readonly Queue _disposed = []; private static readonly TimeSpan ConnectingSocketIdleLimit = TimeSpan.FromMilliseconds(5000); // 5 seconds @@ -41,7 +43,9 @@ public partial class NetState // NetState storage indexed by RingSocket.Id private static readonly NetState[] _netStates = new NetState[MaxConnections]; - // Events buffer for ProcessCompletions + // Events buffer for ProcessCompletions. Bounded by one event per peeked completion + // (maxSockets), doubled for headroom. Undersizing drops DataReceived events whose bytes were + // already committed, leaving them unparsed until the next recv completes. private static readonly RingSocketEvent[] _events = new RingSocketEvent[MaxConnections * 2]; // Listener management @@ -67,6 +71,24 @@ public partial class NetState _socketManager?.WaitForCompletion(timeoutMs); } + /// + /// Wakes the game loop if it is blocked in . Safe from any + /// thread; a no-op before networking is configured or after teardown. The signal is sticky, + /// so a wake racing the loop's decision to sleep is not lost. + /// + public static void Wake() + { + _socketManager?.Ring?.Wake(); + } + + /// + /// True when no queued network work remains for the loop to drain. defers + /// work in several places, so an empty completion queue alone is not enough. + /// + internal static bool IsIdle => + _throttled.Count == 0 && _throttledPending.Count == 0 && + _flushPending.Count == 0 && _pendingDisconnects.Count == 0 && _disposed.Count == 0; + /// /// Gets the listening addresses that the server is bound to. /// @@ -88,20 +110,61 @@ public partial class NetState // Initialize IP rate limiter _ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token); + // Sends in flight per connection; honoured by RIO only (see IIORingGroup). Costs a + // request-queue and completion-queue slot per send, not another buffer. Worst-case added + // latency is roughly completion RTT / this value. + var maxOutstandingSends = ServerConfiguration.GetOrUpdateSetting("network.maxOutstandingSends", 32); + // Initialize IORingGroup - var ring = IORingGroup.Create(queueSize: MaxConnections * 2, maxConnections: MaxConnections); + var ring = IORingGroup.Create( + queueSize: MaxConnections * 2, + maxConnections: MaxConnections, + maxOutstandingSends: maxOutstandingSends + ); + + // Per-connection send buffer: the lever for "send buffer exhausted" disconnects, and the + // per-connection memory ceiling. + var sendBufferSize = GetSendBufferSize(); // Create socket manager which handles buffer pools and socket lifecycle _socketManager = new RingSocketManager( ring, maxSockets: MaxConnections, recvBufferSize: RecvBufferSize, - sendBufferSize: SendBufferSize, + sendBufferSize: sendBufferSize, initialBufferSlabs: 8, maxBufferSlabs: 32 ); } + /// + /// Reads the configured send buffer size, coerced to a power of two of at least the platform + /// allocation granularity. IORingBuffer requires this and would otherwise throw at socket + /// creation rather than at startup. + /// + private static int GetSendBufferSize() + { + var configured = ServerConfiguration.GetOrUpdateSetting("network.sendBufferSize", DefaultSendBufferSize); + var size = Math.Max(MinSendBufferSize, configured); + + if (!BitOperations.IsPow2(size)) + { + size = (int)BitOperations.RoundUpToPowerOf2((uint)size); + } + + if (size != configured) + { + logger.Warning( + "network.sendBufferSize {Configured} is not a power of two of at least {Minimum}; using {Adjusted}", + configured, + MinSendBufferSize, + size + ); + } + + return size; + } + /// /// Starts the network server on configured listening addresses. /// @@ -224,10 +287,19 @@ public partial class NetState if (_ipRateLimiter != null && !_ipRateLimiter.Verify(remoteIP, out var totalAttempts)) { logger.Debug("{Address} Past IP limit threshold ({TotalAttempts})", remoteIP, totalAttempts); + + if (Bans.BanConfiguration.Settings.ReportRateLimitTrips) + { + // Enqueue-only contribution; NOT added to the local firewall set (the limiter already + // gates it here and the OS bouncer drops it at the kernel). + Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.AutoBanDuration, Bans.BanReasons.RateLimit); + } } - else if (Firewall.IsBlocked(remoteIP)) + else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy)) { - logger.Debug("{Address} Firewalled", remoteIP); + // Whatever a hit implies (persisting, promoting to an OS bouncer, contributing to the + // ban channel) is the filter's own business; the accept path just drops the socket. + logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy); } else { @@ -308,6 +380,18 @@ public partial class NetState // Socket must have finished the entire authentication process or be forcibly disconnected if (!ns.SentFirstPacket || !ns.Seeded) { + // Only the totally silent ones are evidence. A connection that sent SOME data and ran out of + // time is far more likely a slow link, and banning those makes the player retry, trip the + // rate limiter, and compound it into an hours-long ban. + if (!ns._receivedData && Bans.BanConfiguration.Settings.ReportBadConnects) + { + Bans.BanChannel.Report( + ns.Address, + Bans.BanConfiguration.Settings.BadConnectDuration, + Bans.BanReasons.SilentConnect + ); + } + ns.Disconnect(null); // Force immediate cleanup - these are unauthenticated connections @@ -484,6 +568,11 @@ public partial class NetState return; } + if (bytesReceived > 0) + { + ns._receivedData = true; + } + // Data is already committed to buffer by RingSocketManager // Decode if encryption is enabled ns.DecryptRecvBuffer(bytesReceived); diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 6c6262ae4..7312603f7 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -60,6 +60,10 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode buffer.Length) + { + SendBufferExhausted(span.Length, buffer.Length); + return; } else { @@ -484,6 +507,31 @@ public partial class NetState : IComparable, IValueLinkListNode + /// Handles a packet that cannot be placed in the send buffer. + /// + /// + /// High unacked means a slow client holding the buffer; needed approaching capacity means the + /// buffer is too small for this shard and network.sendBufferSize should be raised. + /// + private void SendBufferExhausted(int needed, int writable) + { + var sendBuffer = _socket?.SendBuffer; + var unacked = sendBuffer?.InFlightBytes ?? 0; + var capacity = sendBuffer?.PhysicalSize ?? 0; + + logger.Warning( + "{NetState}: send buffer exhausted - needed {Needed} bytes, {Writable} writable, {Unacked} awaiting acknowledgement, {Capacity} capacity. Raise network.sendBufferSize (power of two) if this recurs on healthy connections.", + this, + needed, + writable, + unacked, + capacity + ); + + Disconnect($"Send buffer exhausted (needed {needed}, writable {writable}, unacked {unacked}, capacity {capacity})"); + } + private void StartPacketLog() { try @@ -577,6 +625,37 @@ public partial class NetState : IComparable, IValueLinkListNode, IValueLinkListNode, IValueLinkListNode input, Span output) { - if (input.Length > DefiniteOverflow) + // output.Length < 4 underflows safeOutputLength below (nuint), defeating the hot loop's + // bounds check. Reachable whenever the send buffer is nearly full. + if (input.Length > DefiniteOverflow || output.Length < 4) { return 0; } diff --git a/Projects/Server/PropertyList/ObjectPropertyList.cs b/Projects/Server/PropertyList/ObjectPropertyList.cs index 73288f5e1..58b247990 100644 --- a/Projects/Server/PropertyList/ObjectPropertyList.cs +++ b/Projects/Server/PropertyList/ObjectPropertyList.cs @@ -55,6 +55,12 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable private int _pos; private char[]? _arrayToReturnToPool; + /// + /// True while GetProperties is populating this list. Set by the owning entity so a nested + /// InvalidateProperties can be refused instead of Reset()ing a build already in flight. + /// + internal bool IsBuilding { get; set; } + public ObjectPropertyList(IEntity? e) { Entity = e; @@ -319,8 +325,23 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable private static int GetDefaultLength(int literalLength, int formattedCount) => Math.Max(256, literalLength + formattedCount * 11); + // Reset()/Dispose() return the scratch buffer to the pool. If either lands while a `$"..."` + // handler is still appending, re-rent rather than spanning a null array and throwing out of + // GetProperties. Mobile/Item hold the primary guard; this covers any other caller. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureInterpolationBuffer() + { + if (_arrayToReturnToPool == null) + { + _arrayToReturnToPool = STArrayPool.Shared.Rent(256); + _pos = 0; + } + } + public void AppendLiteral(string value) { + EnsureInterpolationBuffer(); + if (value.Length == 1) { var chars = _arrayToReturnToPool.AsSpan(); @@ -354,6 +375,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(T value) { + EnsureInterpolationBuffer(); + string? s; if (value is IFormattable) { @@ -384,9 +407,11 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(T value, string? format) { - // We support localization '#' cliloc formatter for custom property lists - // This allows someone to build an IPropertyList that creates HTML using the same syntax as LocalizationInterpolationHandler - if (format == "#") + EnsureInterpolationBuffer(); + + // '#' marks an integer argument as a cliloc ("#"). Integers only -- a float/double/decimal + // '#' is the standard numeric format, not a cliloc marker. + if (format == "#" && value is int or uint or long or ulong or short or ushort or byte or sbyte) { AppendLiteral("#"); format = null; @@ -442,6 +467,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(ReadOnlySpan value) { + EnsureInterpolationBuffer(); + if (value.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..))) { _pos += value.Length; @@ -454,6 +481,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(ReadOnlySpan value, int alignment = 0, string? format = null) { + EnsureInterpolationBuffer(); + var leftAlign = false; if (alignment < 0) { @@ -488,6 +517,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable public void AppendFormatted(string? value) { + EnsureInterpolationBuffer(); + if (value?.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)) == true) { _pos += value.Length; diff --git a/Projects/Server/Serialization/BinaryFileReader.cs b/Projects/Server/Serialization/BinaryFileReader.cs index ec76516aa..f8b51c182 100644 --- a/Projects/Server/Serialization/BinaryFileReader.cs +++ b/Projects/Server/Serialization/BinaryFileReader.cs @@ -74,6 +74,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader /// public long Position => _reader.Position; + public TimeSpan AnchoredTimeShift + { + get => _reader.AnchoredTimeShift; + set => _reader.AnchoredTimeShift = value; + } + public void Dispose() { _accessor?.SafeMemoryMappedViewHandle.ReleasePointer(); diff --git a/Projects/Server/Serialization/BufferReader.cs b/Projects/Server/Serialization/BufferReader.cs index 846e9e5dc..bd37f4ff6 100644 --- a/Projects/Server/Serialization/BufferReader.cs +++ b/Projects/Server/Serialization/BufferReader.cs @@ -37,6 +37,8 @@ public class BufferReader : IGenericReader public long Position => _position; public long BufferSize => _buffer.Length; + public TimeSpan AnchoredTimeShift { get; set; } + public BufferReader(byte[] buffer, Dictionary typesDb = null, Encoding encoding = null) { _buffer = buffer; diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index b811d68cb..ab53e2f2f 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -384,6 +384,7 @@ public class BufferWriter : IGenericWriter } [MethodImpl(MethodImplOptions.AggressiveInlining)] + [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] public void WriteDeltaTime(DateTime value) { if (value == DateTime.MinValue) @@ -407,6 +408,21 @@ public class BufferWriter : IGenericWriter Write(value.Ticks - DateTime.UtcNow.Ticks); } + /// + /// Writes the absolute value; re-bases it + /// by the elapsed time since the save started, so downtime does not age it and an + /// unchanged value serializes to identical bytes. + /// + public void WriteAnchoredTime(DateTime value) + { + if (value.Kind == DateTimeKind.Local) + { + value = value.ToUniversalTime(); + } + + Write(value.Ticks); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Write(IPAddress value) { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index a49f213eb..c7323329f 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -114,9 +114,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer using var binFs = new FileStream( Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024 ); - // v4 records are fixed-width 26 bytes; the header carries the type table - // (name lengths vary — 64 bytes per entry is a staging hint, not a contract). - var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; + // v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor + // and the type table (name lengths vary — 64 bytes per entry is a staging hint, not + // a contract). + var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize); var binPosition = 0L; @@ -142,7 +143,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer binPosition += _selfLength; } - idx.Write(4); // Version + idx.Write(5); // Version + + // One anchor for the whole save: the world is frozen from the moment it is stamped. + idx.Write(World.SaveStartTime.Ticks); // The type table is fully known at freeze (AddEntity diverts to the pending // queues while saving) and is written before the records so the loader can @@ -494,6 +498,18 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer var version = dataReader.ReadInt(); + if (version >= 5) + { + // Re-base anchored timestamps by the elapsed time since the save started. + var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc); + var shift = Core.Now - anchor; + _anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero; + + // The whole save shares one anchor. Publish it so payloads without their own + // (GenericPersistence bins) can shift too; indexes load before any of them. + World.LoadTimeShift = _anchoredTimeShift; + } + if (version >= 4) { DeserializeIndexesV4(dataReader, entities); @@ -660,6 +676,9 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer private static List _toDelete; + // From the loaded idx (v5+); zero when the save predates the anchor. + private TimeSpan _anchoredTimeShift; + private unsafe void InternalDeserialize(string filePath, int index, Dictionary typesDb) { using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open); @@ -667,7 +686,10 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) + { + AnchoredTimeShift = _anchoredTimeShift + }; Deserialize(dataReader); diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index 5b8e79c29..2268a86c6 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -98,7 +98,13 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable byte* ptr = null; accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); - var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb); + var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) + { + // These payloads carry no anchor of their own; they inherit the save-wide + // shift stamped while the entity indexes were read (indexes always load + // before persistence payloads — see Persistence.Load). + AnchoredTimeShift = World.LoadTimeShift + }; Deserialize(dataReader); error = dataReader.Position != fileLength diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index 0244a5166..bfa302b39 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -43,6 +43,12 @@ public interface IGenericReader DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc); TimeSpan ReadTimeSpan() => new(ReadLong()); + /// + /// Decodes a legacy delta-time value. Only for reading old-version payloads (version + /// fallbacks and migration replays) — current formats store anchored time and read it + /// with . is + /// obsolete: no current-version format may write delta time. + /// DateTime ReadDeltaTime() { return ReadLong() switch @@ -52,6 +58,37 @@ public interface IGenericReader var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc) }; } + + /// + /// Elapsed time between the loaded save starting and this load, applied by + /// . Zero when the source carries no anchor. + /// + TimeSpan AnchoredTimeShift => TimeSpan.Zero; + + DateTime ReadAnchoredTime() + { + var value = ReadDateTime(); + + if (value == DateTime.MinValue || value == DateTime.MaxValue) + { + return value; + } + + var shift = AnchoredTimeShift; + if (shift == TimeSpan.Zero) + { + return value; + } + + var ticks = value.Ticks + shift.Ticks; + + if (ticks >= DateTime.MaxValue.Ticks) + { + return DateTime.MaxValue; + } + + return ticks <= 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Utc); + } decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]); int ReadEncodedInt() { diff --git a/Projects/Server/Serialization/IGenericWriter.cs b/Projects/Server/Serialization/IGenericWriter.cs index 9c9563139..4162655de 100644 --- a/Projects/Server/Serialization/IGenericWriter.cs +++ b/Projects/Server/Serialization/IGenericWriter.cs @@ -40,7 +40,11 @@ public interface IGenericWriter void Write(decimal value); void WriteEncodedInt(int value); void Write(DateTime value); + + [Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")] void WriteDeltaTime(DateTime value); + + void WriteAnchoredTime(DateTime value); void Write(IPAddress value); void Write(TimeSpan value); void Write(Point3D value); diff --git a/Projects/Server/Serialization/UnmanagedDataReader.cs b/Projects/Server/Serialization/UnmanagedDataReader.cs index 7c8b5da1e..aa3916ee9 100644 --- a/Projects/Server/Serialization/UnmanagedDataReader.cs +++ b/Projects/Server/Serialization/UnmanagedDataReader.cs @@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader /// public long Position { get; private set; } + public TimeSpan AnchoredTimeShift { get; set; } + /// /// Read bits of data raw from a serialized file using Little-endian. /// diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index ff9b3a30b..fd69557b7 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -34,14 +34,13 @@ - + - - + + - - - + + diff --git a/Projects/Server/Timer/Timer.TimerWheel.cs b/Projects/Server/Timer/Timer.TimerWheel.cs index 8c9dc47fd..2ab3ce547 100644 --- a/Projects/Server/Timer/Timer.TimerWheel.cs +++ b/Projects/Server/Timer/Timer.TimerWheel.cs @@ -51,8 +51,15 @@ public partial class Timer } } + /// + /// Milliseconds of simulated time one wheel turn advances. + /// + public static int TickRate => _tickRate; + public static void Slice(long tickCount) { + EventLoopProfiler.WheelSlice(tickCount - _lastTickTurned); + var deltaSinceTurn = tickCount - _lastTickTurned; while (deltaSinceTurn >= _tickRate) { diff --git a/Projects/Server/Utilities/IPAddressUtility.cs b/Projects/Server/Utilities/IPAddressUtility.cs new file mode 100644 index 000000000..12f81e516 --- /dev/null +++ b/Projects/Server/Utilities/IPAddressUtility.cs @@ -0,0 +1,240 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: IPAddressUtility.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Numerics; + +namespace Server; + +/// +/// Low-level IPAddress conversion and parsing helpers shared by the firewall, ban channel, and +/// blocklist. All members are allocation-free (stack buffers only) so they are safe on hot accept +/// paths and inside tight parse loops. +/// +public static class IPAddressUtility +{ + // Converts an IPAddress to a UInt128 in IPv6 format. + // The IsIPv4MappedToIPv6 clause below looks redundant (the BCL only ever sets it on InterNetworkV6), + // but it guards the v4 -> UInt128 -> IPAddress round-trip, which can return a mapped v6 address for + // what is really a v4 one. + //TODO Rework as an explicit "to canonical v6 bits" step that needs no family check + // (see dev-docs/networking-packets.md, "IP Address Normalization") + public static UInt128 ToUInt128(this IPAddress ip) + { + if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6) + { + Span integer = stackalloc byte[4]; + return !ip.TryWriteBytes(integer, out _) + ? (UInt128)0 + : new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer)); + } + + Span bytes = stackalloc byte[16]; + if (!ip.TryWriteBytes(bytes, out _)) + { + return 0; + } + + var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); + var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); + + return new UInt128(high, low); + } + + // Converts a UInt128 in IPv6 format to an IPAddress + public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false) + { + // IPv4 mapped IPv6 address + if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL) + { + var newAddress = IPAddress.HostToNetworkOrder((int)value); + return new IPAddress(unchecked((uint)newAddress)); + } + + Span bytes = stackalloc byte[16]; // 128 bits for IPv6 address + ((IBinaryInteger)value).WriteBigEndian(bytes); + + return new IPAddress(bytes); + } + + /// + /// Parses a.b.c.d/n, ::/n, or a bare address (treated as a single-host range) into an + /// inclusive range in normalized IPv6 form. A bare IPv4 prefix is widened by 96 + /// bits so v4 and v6 ranges are directly comparable. Returns false on anything malformed. + /// + public static bool TryParseCidrRange(ReadOnlySpan cidr, out UInt128 min, out UInt128 max) + { + min = default; + max = default; + + var slash = cidr.IndexOf('/'); + if (!IPAddress.TryParse(slash >= 0 ? cidr[..slash] : cidr, out var ip)) + { + return false; + } + + var isV6 = ip.AddressFamily == AddressFamily.InterNetworkV6; + var maxPrefixLength = isV6 ? 128 : 32; + int prefixLength; + + if (slash < 0) + { + prefixLength = maxPrefixLength; + } + else if (!int.TryParse(cidr[(slash + 1)..], out prefixLength) || + prefixLength < 0 || prefixLength > maxPrefixLength) + { + return false; + } + + if (!isV6) + { + prefixLength += 96; // 32 -> 128 + } + + Span bytes = stackalloc byte[16]; + ip.WriteMappedIPv6To(bytes); + + min = Utility.CreateCidrAddress(bytes, prefixLength, false); + max = Utility.CreateCidrAddress(bytes, prefixLength, true); + return true; + } + + /// Extracts the big-endian uint of an address. + public static bool TryV4(IPAddress ip, out uint v) + { + Span b = stackalloc byte[4]; + if (ip.TryWriteBytes(b, out var n) && n == 4) + { + v = ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3]; + return true; + } + + v = 0; + return false; + } + + /// + /// Extracts the embedded v4 uint from a v4-mapped-v6 address directly from the mapped bytes, + /// avoiding the allocation of . + /// + public static bool TryMappedV4(IPAddress ip, out uint v) + { + Span b = stackalloc byte[16]; + if (ip.TryWriteBytes(b, out var n) && n == 16) + { + v = ((uint)b[12] << 24) | ((uint)b[13] << 16) | ((uint)b[14] << 8) | b[15]; + return true; + } + + v = 0; + return false; + } + + /// Parses a dotted-quad IPv4 literal into a big-endian uint. Allocation-free, strict. + public static bool TryParseV4(ReadOnlySpan s, out uint v) + { + v = 0; + uint acc = 0; + int octet = 0, digits = 0, dots = 0; + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + if (c == '.') + { + if (digits == 0 || octet > 255) + { + return false; + } + + acc = (acc << 8) | (uint)octet; + dots++; + octet = 0; + digits = 0; + } + else if (c is >= '0' and <= '9') + { + octet = octet * 10 + (c - '0'); + if (++digits > 3) + { + return false; + } + } + else + { + return false; + } + } + + if (dots != 3 || digits == 0 || octet > 255) + { + return false; + } + + v = (acc << 8) | (uint)octet; + return true; + } + + /// + /// UTF-8/ASCII byte overload of , mirroring its + /// validation exactly so the blocklist can parse dotted-quads straight from file bytes with no + /// per-line string allocation. + /// + public static bool TryParseV4(ReadOnlySpan s, out uint v) + { + v = 0; + uint acc = 0; + int octet = 0, digits = 0, dots = 0; + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + if (c == (byte)'.') + { + if (digits == 0 || octet > 255) + { + return false; + } + + acc = (acc << 8) | (uint)octet; + dots++; + octet = 0; + digits = 0; + } + else if (c is >= (byte)'0' and <= (byte)'9') + { + octet = octet * 10 + (c - '0'); + if (++digits > 3) + { + return false; + } + } + else + { + return false; + } + } + + if (dots != 3 || digits == 0 || octet > 255) + { + return false; + } + + v = (acc << 8) | (uint)octet; + return true; + } +} diff --git a/Projects/Server/Utilities/NetworkUtilities.cs b/Projects/Server/Utilities/NetworkUtilities.cs index 7d54ddf5e..deeffb66f 100644 --- a/Projects/Server/Utilities/NetworkUtilities.cs +++ b/Projects/Server/Utilities/NetworkUtilities.cs @@ -1,6 +1,7 @@ +using System; using System.Net; using System.Net.Sockets; -using Server.Network; +using Server.Collections; namespace Server; @@ -14,36 +15,42 @@ public static class NetworkUtilities _ => false }; - private static readonly IFirewallEntry[] _privateNetworkV4 = - [ - new CidrFirewallEntry("127.0.0.1/8"), - new CidrFirewallEntry("192.168.0.0/16"), - new CidrFirewallEntry("10.0.0.0/8"), - new CidrFirewallEntry("172.16.0.0/12"), - new CidrFirewallEntry("169.254.0.0/16"), - new CidrFirewallEntry("100.64.0.0/10") - ]; + // These are constant reserved ranges, not firewall entries -- they only ever answer "is this address + // in one of these blocks?", which is exactly what SortedRangeIndex is for. Building them through the + // firewall entry types was a convenience that made core depend on the firewall for something that has + // nothing to do with banning. + private static readonly SortedRangeIndex _privateNetworkV4 = BuildIndex( + "127.0.0.1/8", + "192.168.0.0/16", + "10.0.0.0/8", + "172.16.0.0/12", + "169.254.0.0/16", + "100.64.0.0/10" + ); - private static readonly IFirewallEntry[] _privateNetworkV6 = - [ - new CidrFirewallEntry("fc00::/7"), - new CidrFirewallEntry("fe80::/10") - ]; + private static readonly SortedRangeIndex _privateNetworkV6 = BuildIndex( + "fc00::/7", + "fe80::/10" + ); - public static bool IsPrivateNetworkV4(this IPAddress ip) + private static SortedRangeIndex BuildIndex(params ReadOnlySpan cidrs) { - for (var i = 0; i < _privateNetworkV4.Length; i++) + var ranges = new SortedRangeIndex.Range[cidrs.Length]; + for (var i = 0; i < cidrs.Length; i++) { - if (_privateNetworkV4[i].IsBlocked(ip)) + if (!IPAddressUtility.TryParseCidrRange(cidrs[i], out var min, out var max)) { - return true; + throw new ArgumentException($"Invalid reserved-network CIDR \"{cidrs[i]}\""); } + + ranges[i] = new SortedRangeIndex.Range(min, max); } - return false; + Array.Sort(ranges, SortedRangeIndex.ByMin); + return SortedRangeIndex.Build(ranges); } - public static bool IsPrivateNetworkV6(this IPAddress ip) => - _privateNetworkV6[0].IsBlocked(ip) || - _privateNetworkV6[1].IsBlocked(ip); + public static bool IsPrivateNetworkV4(this IPAddress ip) => _privateNetworkV4.Contains(ip.ToUInt128()); + + public static bool IsPrivateNetworkV6(this IPAddress ip) => _privateNetworkV6.Contains(ip.ToUInt128()); } diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index 4f33220aa..9d7cd9f3b 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -108,45 +108,6 @@ public static partial class Utility } } - // Converts an IPAddress to a UInt128 in IPv6 format - public static UInt128 ToUInt128(this IPAddress ip) - { - if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6) - { - Span integer = stackalloc byte[4]; - return !ip.TryWriteBytes(integer, out _) - ? (UInt128)0 - : new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer)); - } - - Span bytes = stackalloc byte[16]; - if (!ip.TryWriteBytes(bytes, out _)) - { - return 0; - } - - var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); - var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); - - return new UInt128(high, low); - } - - // Converts a UInt128 in IPv6 format to an IPAddress - public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false) - { - // IPv4 mapped IPv6 address - if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL) - { - var newAddress = IPAddress.HostToNetworkOrder((int)value); - return new IPAddress(unchecked((uint)newAddress)); - } - - Span bytes = stackalloc byte[16]; // 128 bits for IPv6 address - ((IBinaryInteger)value).WriteBigEndian(bytes); - - return new IPAddress(bytes); - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static UInt128 CreateCidrAddress(ReadOnlySpan bytes, int prefixLength, bool isMax) { @@ -1089,28 +1050,16 @@ public static partial class Utility return; } - using var queue = PooledRefQueue.Create(); foreach (var (key, value) in dictionary) { - if (serializableKey) - { - if (key == null || ((ISerializable)key).Deleted) - { - queue.Enqueue(key); - } - } - else - { - if (value == null || ((ISerializable)value).Deleted) - { - queue.Enqueue(key); - } - } - } + var deleted = serializableKey + ? ((ISerializable)key).Deleted + : value == null || ((ISerializable)value).Deleted; - while (queue.Count > 0) - { - dictionary.Remove(queue.Dequeue()); + if (deleted) + { + dictionary.Remove(key); + } } dictionary.TrimExcess(); diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 2c9e6d216..c00fc85f6 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -93,6 +93,21 @@ public static class World public static string SavePath { get; private set; } public static WorldState WorldState { get; private set; } public static bool Saving => WorldState == WorldState.Saving; + + /// + /// UTC time the current or most recent world save started. Written into save indexes so + /// anchored timestamps can be re-based by the downtime at load. + /// + public static DateTime SaveStartTime { get; internal set; } + + /// + /// The anchored-time shift for the save currently being loaded: the downtime between the + /// save's start and this load. Stamped while entity indexes are read (they all carry the + /// same anchor, since the whole save shares one ) and applied + /// to every reader of that save's files — including + /// payloads, which carry no anchor of their own. Zero for saves that predate the anchor. + /// + public static TimeSpan LoadTimeShift { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; @@ -287,6 +302,10 @@ public static class World WorldState = WorldState.Saving; + // The world is frozen from here: one anchor for the whole save. Written into save + // indexes so anchored timestamps can be re-based by the downtime at load. + SaveStartTime = Core.Now; + Broadcast(0x35, true, "The world is saving, please wait."); logger.Information("Saving world"); diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 076f88141..a38af9c3d 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Reflection; using System.Threading; @@ -61,6 +61,15 @@ internal static class TestServerInitializer AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); SkillsInfo.Configure(); + + // Seed the loop clock as Main.cs does before the Configure sweep; otherwise Core.Now is + // DateTime.MinValue for the whole test host. + Core._now = DateTime.UtcNow; + + // Timer wheel must exist before NetState.Configure(), which schedules a recurring + // sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs + // before AssemblyHandler.Invoke("Configure")). + Timer.Init(0); Server.Network.NetState.Configure(); TestMapDefinitions.ConfigureTestMapDefinitions(); @@ -91,7 +100,8 @@ internal static class TestServerInitializer } World.Configure(); - Timer.Init(0); + // Registers the Accounts entity persistence; without it no test can construct an Account. + Server.Accounting.Accounts.Configure(); RaceDefinitions.Configure(); MovementImpl.Configure(); PathFollower.Configure(); diff --git a/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs new file mode 100644 index 000000000..29a1b818f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Accounting/AccountPasswordTests.cs @@ -0,0 +1,74 @@ +using System; +using Server.Accounting; +using Server.Accounting.Security; +using Xunit; + +namespace Server.Tests.Accounting; + +[Collection("Sequential UOContent Tests")] +public class AccountPasswordTests : IDisposable +{ + private const string Password = "hunter2"; + + // CurrentAlgorithm is process-wide state shared with the rest of the collection. + private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; + + public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + + [Theory] + [InlineData(PasswordProtectionAlgorithm.SHA1)] + [InlineData(PasswordProtectionAlgorithm.SHA2)] + [InlineData(PasswordProtectionAlgorithm.PBKDF2)] + [InlineData(PasswordProtectionAlgorithm.Argon2)] + public void NewAccount_CanLogIn(PasswordProtectionAlgorithm algorithm) + { + AccountSecurity.CurrentAlgorithm = algorithm; + var account = new Account($"new-{algorithm}-user", Password); + + Assert.Equal(algorithm, account.PasswordAlgorithm); + Assert.True(account.CheckPassword(Password)); + Assert.False(account.CheckPassword("wrong-password")); + } + + // SetPassword assigns PasswordAlgorithm before deriving the phrase from it. Reversed, the hash + // is salted by the outgoing algorithm's rule but stored under the incoming one, which verifies + // once and then never again. + [Theory] + [InlineData(PasswordProtectionAlgorithm.SHA1)] + [InlineData(PasswordProtectionAlgorithm.SHA2)] + [InlineData(PasswordProtectionAlgorithm.PBKDF2)] + public void UpgradingAlgorithm_DoesNotLockTheAccountOut(PasswordProtectionAlgorithm from) + { + AccountSecurity.CurrentAlgorithm = from; + var account = new Account($"upgrade-{from}-user", Password); + Assert.True(account.CheckPassword(Password)); + + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + + Assert.True(account.CheckPassword(Password)); + Assert.Equal(PasswordProtectionAlgorithm.Argon2, account.PasswordAlgorithm); + + // Must verify against what the rehash wrote. + Assert.True(account.CheckPassword(Password)); + Assert.False(account.CheckPassword("wrong-password")); + } + + [Fact] + public void StaleArgon2Parameters_AreRehashedOnLogin() + { + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + var account = new Account("stale-params-user", Password); + + // The shipping default before this change: Argon2i, m=8192, t=3, p=1. + account.Password = + "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + Assert.True(account.CheckPassword(Password)); + Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", account.Password); + + // Already current: verifying again must not rewrite the hash. + var afterFirst = account.Password; + Assert.True(account.CheckPassword(Password)); + Assert.Equal(afterFirst, account.Password); + } +} diff --git a/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs new file mode 100644 index 000000000..5972e5eb9 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Accounting/PasswordWorkerTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Threading; +using Server.Accounting; +using Server.Accounting.Security; +using Xunit; + +namespace Server.Tests.Accounting; + +[Collection("Sequential UOContent Tests")] +public class PasswordWorkerTests : IDisposable +{ + private const string Password = "hunter2"; + + private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; + + public PasswordWorkerTests() => AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + + public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + + private static Account CreateAccount(string username) => + Accounts.GetAccount(username) as Account ?? new Account(username, Password); + + /// + /// Enqueues work, then pumps the loop context until or the deadline. + /// + /// The context pins itself to the thread that constructed it and refuses ExecuteTasks + /// from any other. The fixture's belongs to whichever thread built the fixture, and xUnit gives + /// no guarantee that a test method runs on that thread even inside a sequential collection -- + /// so this owns one for the duration and puts the original back. Pumping the fixture's context + /// passed locally and failed on CI. + /// + private static void PumpUntil(Action enqueue, Func complete, int timeoutSeconds = 20) + { + var original = Core.LoopContext; + var owned = new EventLoopContext(); + Core.LoopContext = owned; + + try + { + enqueue(); + + var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); + + while (!complete() && DateTime.UtcNow < deadline) + { + owned.ExecuteTasks(); + Thread.Sleep(5); + } + + // Anything that landed between the last pump and the final check. + owned.ExecuteTasks(); + } + finally + { + Core.LoopContext = original; + } + } + + private static PasswordJob JobFor(Account account, string submitted) => + new() + { + Account = account, + StoredHash = account.Password, + VerifyPhrase = account.GetVerifyPhrase(submitted), + HashPhrase = account.NeedsPasswordUpgrade() ? account.GetRehashPhrase(submitted) : null, + StoredAlgorithm = account.PasswordAlgorithm, + TargetAlgorithm = AccountSecurity.CurrentAlgorithm + }; + + /// + /// Drives the real queue rather than ComputeInline. A job with no NetState attached -- an + /// admin password change -- was being dropped by the liveness check, which read a null State as + /// a dead connection, so the change silently never happened and its callback never fired. + /// + [Fact] + public void RunsAJobThatHasNoConnectionAttached() + { + var account = CreateAccount("offloop-no-netstate-user"); + var applied = false; + + var job = new PasswordJob + { + Account = account, + HashPhrase = account.GetRehashPhrase("a-queued-password"), + TargetAlgorithm = AccountSecurity.CurrentAlgorithm, + OnComplete = (_, outcome) => applied = outcome.Hash != null + }; + + PumpUntil(() => Assert.True(PasswordWorker.TryEnqueue(job)), () => applied); + + Assert.True(applied); + Assert.True(account.CheckPassword("a-queued-password")); + } + + [Fact] + public void VerifiesTheCorrectPassword() + { + var account = CreateAccount("offloop-correct-user"); + + var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); + + Assert.True(outcome.Verified); + } + + [Fact] + public void RejectsTheWrongPassword() + { + var account = CreateAccount("offloop-wrong-user"); + + var outcome = PasswordWorker.ComputeInline(JobFor(account, "not-the-password")); + + Assert.False(outcome.Verified); + Assert.Null(outcome.Hash); + } + + [Fact] + public void ProducesNoUpgradeWhenParametersAreCurrent() + { + var account = CreateAccount("offloop-current-user"); + + var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); + + Assert.True(outcome.Verified); + Assert.Null(outcome.Hash); + } + + [Fact] + public void ProducesAnUpgradeWhenParametersAreStale() + { + var account = CreateAccount("offloop-stale-user"); + + // The shipping default before #2562: Argon2i, m=8192, t=3, p=1. + account.Password = + "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + var outcome = PasswordWorker.ComputeInline(JobFor(account, Password)); + + Assert.True(outcome.Verified); + Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", outcome.Hash); + } + + [Fact] + public void ProducesNoUpgradeWhenThePasswordIsWrong() + { + var account = CreateAccount("offloop-wrong-stale-user"); + account.Password = + "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + var outcome = PasswordWorker.ComputeInline(JobFor(account, "not-the-password")); + + Assert.False(outcome.Verified); + Assert.Null(outcome.Hash); + } + + [Fact] + public void AppliesAWrite() + { + var account = CreateAccount("offloop-apply-user"); + var upgraded = Argon2PasswordProtection.Instance.EncryptPassword(Password); + + account.ApplyPasswordWrite(upgraded, PasswordProtectionAlgorithm.Argon2); + + Assert.Equal(upgraded, account.Password); + Assert.True(account.CheckPassword(Password)); + } + + /// + /// Writes apply in dispatch order, which is what makes a guard unnecessary: dispatch is on the + /// loop, one worker drains FIFO, and results return through the loop context in that same order. + /// A second worker thread would break this and would need ordering reintroduced. + /// + [Fact] + public void WritesApplyInDispatchOrder() + { + var account = CreateAccount("offloop-two-writes-user"); + var done = 0; + + PumpUntil( + () => + { + for (var i = 1; i <= 2; i++) + { + Assert.True( + PasswordWorker.TryEnqueue( + new PasswordJob + { + Account = account, + HashPhrase = account.GetRehashPhrase($"password-{i}"), + StoredAlgorithm = account.PasswordAlgorithm, + TargetAlgorithm = AccountSecurity.CurrentAlgorithm, + OnComplete = (_, _) => done++ + } + ) + ); + } + }, + () => done >= 2 + ); + + Assert.Equal(2, done); + Assert.True(account.CheckPassword("password-2")); + Assert.False(account.CheckPassword("password-1")); + } + + [Theory] + [InlineData(PasswordProtectionAlgorithm.SHA1)] + [InlineData(PasswordProtectionAlgorithm.SHA2)] + public void UsesTheUsernameSaltedPhraseForShaAccounts(PasswordProtectionAlgorithm algorithm) + { + AccountSecurity.CurrentAlgorithm = algorithm; + var account = CreateAccount($"offloop-phrase-{algorithm}-user"); + + // Verification must use the algorithm the hash was stored under... + Assert.Equal($"{account.Username}{Password}", account.GetVerifyPhrase(Password)); + + // ...and a rehash the one it is moving to. Swapping these is the #2562 lockout. + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + Assert.Equal(Password, account.GetRehashPhrase(Password)); + } + + [Fact] + public void UsesTheBarePasswordForArgon2Accounts() + { + var account = CreateAccount("offloop-phrase-argon2-user"); + + Assert.Equal(Password, account.GetVerifyPhrase(Password)); + Assert.Equal(Password, account.GetRehashPhrase(Password)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs index effd2da88..a0ef97a56 100644 --- a/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs +++ b/Projects/UOContent.Tests/Tests/Accounting/Security/PasswordProtectionTest.cs @@ -74,4 +74,89 @@ public class PasswordProtectionTest Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password")); } + + /// + /// Literal digests of , so the stored format cannot drift. These are + /// compared as strings against what is already in every account database -- a casing or encoding + /// change would lock out every SHA and MD5 account on the shard at once. + /// + [Theory] + [InlineData("MD5", "52284053181040AC90DBDE74A0E7FF5E")] + [InlineData("SHA1", "9AC635509803AAE2D8312BA1879289259A50C5F0")] + [InlineData( + "SHA2", + "5A727BFF8F8E08A24BDF6B0CD5065F30A1F8E0060B857BB8AFD6955BE0ACBC489DA63F19B8F4CF08D73DE4069CF4B" + + "29D94B353F31513B2FB2D9382EFE15AE975" + )] + public void HashAlgorithm_StoredFormatIsStable(string algorithmType, string expected) + { + var protection = algorithmType switch + { + "SHA1" => HashAlgorithmPasswordProtection.SHA1Instance, + "SHA2" => HashAlgorithmPasswordProtection.SHA2Instance, + _ => HashAlgorithmPasswordProtection.MD5Instance, + }; + + Assert.Equal(expected, protection.EncryptPassword(plainPassword)); + Assert.True(protection.ValidatePassword(expected, plainPassword)); + } + + // The shipping default before this change, as a literal so it cannot drift with the configured + // defaults. Password: "hunter2". + private const string LegacyArgon2iHash = + "$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + [Fact] + public void Argon2_ValidatesLegacyArgon2iHash() + { + Assert.True(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "hunter2")); + Assert.False(Argon2PasswordProtection.Instance.ValidatePassword(LegacyArgon2iHash, "wrong")); + } + + [Theory] + // type, memory, time, parallelism -> expected NeedsRehash + [InlineData("argon2id", 16384, 1, 1, false)] // current defaults + [InlineData("argon2i", 8192, 3, 1, true)] // the old shipping default + [InlineData("argon2id", 8192, 1, 1, true)] // right type, stale memory + [InlineData("argon2id", 16384, 3, 1, true)] // right type, stale iterations + [InlineData("argon2id", 16384, 1, 2, true)] // right type, stale parallelism + [InlineData("argon2i", 16384, 1, 1, true)] // right cost, stale type + public void Argon2_NeedsRehash_ComparesTypeAndCost( + string type, int memory, int time, int parallelism, bool expected + ) + { + var hash = $"${type}$v=19$m={memory},t={time},p={parallelism}$" + + "LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw"; + + Assert.Equal(expected, Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + // Digest and salt lengths are decoded base64 sizes rather than parameter-list entries, so they + // need their own literals. Current type and cost throughout; only a length differs. + [Theory] + // 16-byte digest: 22 base64 chars instead of the 43 a 32-byte digest encodes to. + [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4g")] + // 8-byte salt: 11 base64 chars instead of the 22 a 16-byte salt encodes to. + [InlineData("$argon2id$v=19$m=16384,t=1,p=1$LD1XJz7P3wQ$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw")] + public void Argon2_NeedsRehash_ComparesSaltAndDigestLengths(string hash) + { + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + [Theory] + [InlineData("")] + [InlineData("not-a-hash")] + public void Argon2_NeedsRehash_IsTrueForUnparseableHashes(string hash) + { + Assert.True(Argon2PasswordProtection.Instance.NeedsRehash(hash)); + } + + [Fact] + public void NonArgon2Protections_NeverNeedRehash() + { + Assert.False(PBKDF2PasswordProtection.Instance.NeedsRehash("anything")); + Assert.False(HashAlgorithmPasswordProtection.SHA2Instance.NeedsRehash("anything")); + Assert.False(HashAlgorithmPasswordProtection.SHA1Instance.NeedsRehash("anything")); + Assert.False(HashAlgorithmPasswordProtection.MD5Instance.NeedsRehash("anything")); + } } diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs deleted file mode 100644 index 4716dd239..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/CagTreeBuilderTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Linq; -using Server.Commands; -using Server.Items; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -[Collection("Sequential UOContent Tests")] -public class CagTreeBuilderTests -{ - [Fact] - public void BuildTree_creates_nested_categories_and_resolves_types() - { - var index = new ObjectIndexFile - { - Objects = - [ - new ObjectIndexEntry - { - Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", - Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 - } - ] - }; - - var root = CAGLoader.BuildTree(index); - - var items = Assert.IsType(FindChild(root, "Items")); - var weapons = Assert.IsType(FindChild(items, "Weapons")); - var swords = Assert.IsType(FindChild(weapons, "Swords")); - - var leaf = Assert.IsType(swords.Nodes[0]); - Assert.Equal(typeof(Katana), leaf.Type); - Assert.Equal(0x13FF, leaf.ItemID); - } - - [Fact] - public void BuildTree_keeps_multiple_objects_in_one_category() - { - var index = new ObjectIndexFile - { - Objects = - [ - new ObjectIndexEntry { Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 }, - new ObjectIndexEntry { Type = "Longsword", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x0F5E, Hue = 0 } - ] - }; - - var root = CAGLoader.BuildTree(index); - var swords = (CAGCategory)FindNestedCategory(root, "Items", "Weapons", "Swords"); - - var leafTypes = swords.Nodes.OfType().Select(o => o.Type).ToList(); - Assert.Contains(typeof(Katana), leafTypes); - Assert.Contains(typeof(Server.Items.Longsword), leafTypes); - } - - private static CAGNode FindChild(CAGCategory parent, string title) - { - foreach (var node in parent.Nodes) - { - if (node.Title == title) - { - return node; - } - } - - throw new Xunit.Sdk.XunitException($"No child '{title}'."); - } - - private static CAGNode FindNestedCategory(CAGCategory root, params string[] titles) - { - CAGNode current = root; - foreach (var title in titles) - { - current = FindChild((CAGCategory)current, title); - } - - return current; - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs deleted file mode 100644 index 8fb4a225d..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/CategorizationSyncTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Server.Commands; -using Server.Items; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -public class CategorizationSyncTests -{ - private static CAGJson Cat(string category, params System.Type[] types) => - new() - { - Category = category, - Objects = types.Select(t => new CAGObject { Type = t }).ToArray() - }; - - [Fact] - public void Reconcile_appends_missing_types_to_uncategorized() - { - var categorization = new List { Cat("Items.Weapons.Swords", typeof(Katana)) }; - var discovered = new List { typeof(Katana), typeof(Runebook) }; - - var (updated, report) = CategorizationSync.Reconcile(categorization, discovered); - - Assert.Contains("Runebook", report.Appended); - Assert.Empty(report.Orphaned); - - var uncategorized = Assert.Single(updated, c => c.Category == "Items.Uncategorized"); - Assert.Contains(uncategorized.Objects, o => o.Type == typeof(Runebook)); - } - - [Fact] - public void Reconcile_reports_orphans_not_in_discovered() - { - var categorization = new List { Cat("Items.Weapons.Swords", typeof(Katana)) }; - var discovered = new List { typeof(Runebook) }; - - var (_, report) = CategorizationSync.Reconcile(categorization, discovered); - - Assert.Contains("Katana", report.Orphaned); - Assert.Contains("Runebook", report.Appended); - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs deleted file mode 100644 index 860822597..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheBuilderTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using Server.Commands; -using Server.Items; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -public class ObjectCacheBuilderTests -{ - [Fact] - public void Build_produces_index_row_and_detail_chunk() - { - var extracted = new ExtractedObject( - typeof(Runebook), - "item", - "Items.Skill Items.Magical", - new LeanMetadata(8901, 0x461, "runebook", 1041267), - [new CtorDoc()], - [new PropertyDoc { Name = "Hue", Type = "int" }], - [new OplLine { Cliloc = 1041267 }], - "Item" - ); - - var (index, chunks) = ObjectCacheBuilder.Build([extracted], "2026-07-19T00:00:00Z"); - - var row = Assert.Single(index.Objects); - Assert.Equal("Runebook", row.Type); - Assert.Equal("items.skill-items.magical", row.Chunk); - Assert.Equal(8901, row.ItemID); - - var chunk = Assert.Contains("items.skill-items.magical", chunks); - Assert.Contains("Runebook", chunk.Keys); - Assert.Equal("Item", chunk["Runebook"].BaseType); - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs deleted file mode 100644 index 96521bdfd..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectCacheGeneratorTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Generic; -using Server.Commands; -using Server.Items; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -[Collection("Sequential UOContent Tests")] -public class ObjectCacheGeneratorTests -{ - [Fact] - public void Generate_builds_index_and_chunks_from_extracted_objects() - { - var discovered = new List { typeof(Runebook), typeof(Katana) }; - var categorization = new List(); // empty -> both land in Items.Uncategorized - - var result = ObjectCacheGenerator.Generate(categorization, discovered); - - Assert.Equal(2, result.Index.Objects.Count); - Assert.NotEmpty(result.Chunks); - - var runebook = Assert.Single(result.Index.Objects, o => o.Type == "Runebook"); - Assert.True(runebook.ItemID > 0); - Assert.Equal(1041267, runebook.Cliloc); - Assert.Equal("items.uncategorized", runebook.Chunk); - - Assert.Contains("Runebook", result.Report.Appended); - Assert.Contains("items.uncategorized", result.Chunks.Keys); - Assert.Contains("Runebook", result.Chunks["items.uncategorized"].Keys); - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs deleted file mode 100644 index d36f8bb8a..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectDiscoveryTests.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Server.Commands; -using Server.Items; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -[Collection("Sequential UOContent Tests")] -public class ObjectDiscoveryTests -{ - [Fact] - public void Discover_includes_concrete_constructibles_and_excludes_abstract() - { - var types = ObjectIntrospection.DiscoverConstructibleTypes(); - - Assert.Contains(typeof(Katana), types); - Assert.Contains(typeof(Runebook), types); - Assert.DoesNotContain(typeof(BaseWeapon), types); // abstract - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs deleted file mode 100644 index 268ee13e0..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIndexSerializationTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.IO; -using Server.Commands; -using Server.Json; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -public class ObjectIndexSerializationTests -{ - [Fact] - public void ObjectIndexFile_round_trips_through_json() - { - var index = new ObjectIndexFile - { - GeneratedUtc = "2026-07-19T00:00:00Z", - Objects = - [ - new ObjectIndexEntry - { - Type = "Katana", - Entity = "item", - Category = "Items.Weapons.Swords", - Chunk = "items.weapons.swords", - ItemID = 8901, - Hue = 0x461, - Name = "katana", - Cliloc = 1041267 - } - ] - }; - - var tempPath = Path.GetTempFileName(); - try - { - JsonConfig.Serialize(tempPath, index); - var roundTripped = JsonConfig.Deserialize(tempPath); - - Assert.NotNull(roundTripped); - var entry = Assert.Single(roundTripped.Objects); - - Assert.Equal("Katana", entry.Type); - Assert.Equal("items.weapons.swords", entry.Chunk); - Assert.Equal(8901, entry.ItemID); - Assert.Equal(0x461, entry.Hue); - Assert.Equal(1041267, entry.Cliloc); - } - finally - { - File.Delete(tempPath); - } - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs deleted file mode 100644 index 6c35df2f8..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionCtorsTests.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Server.Commands; -using Server.Items; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -[Collection("Sequential UOContent Tests")] -public class ObjectIntrospectionCtorsTests -{ - [Fact] - public void ExtractCtors_lists_both_constructible_runebook_overloads() - { - // Runebook has [Constructible] Runebook() and [Constructible] Runebook(int maxCharges). - var ctors = ObjectIntrospection.ExtractCtors(typeof(Runebook)); - - Assert.Equal(2, ctors.Count); - Assert.Contains(ctors, c => c.Parameters.Count == 0); - - var parameterized = Assert.Single(ctors, c => c.Parameters.Count == 1); - Assert.Equal("maxCharges", parameterized.Parameters[0].Name); - Assert.Equal("int", parameterized.Parameters[0].Type); - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs deleted file mode 100644 index 5ddb65278..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionLeanTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Server.Commands; -using Server.Items; -using Server.Tests; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -[Collection("Sequential UOContent Tests")] -public class ObjectIntrospectionLeanTests -{ - [SkippableFact] - public void ExtractLean_reads_item_id_from_a_weapon() - { - // Requires client TileData: ExtractLean clamps itemID > TileData.MaxItemValue to 1, and - // MaxItemValue is 0 when tiledata.mul is absent (CI), so the real 0x13FF only survives with data. - TileDataRequirement.SkipIfMissing(); - - // Katana ctor is base(0x13FF) — era-independent. - var lean = ObjectIntrospection.ExtractLean(typeof(Katana)); - Assert.Equal(0x13FF, lean.ItemID); - } - - [Fact] - public void ExtractLean_reads_hue_and_cliloc_from_a_runebook() - { - // Runebook sets Hue = 0x461 and LabelNumber 1041267 regardless of era. - var lean = ObjectIntrospection.ExtractLean(typeof(Runebook)); - Assert.Equal(0x461, lean.Hue); - Assert.Equal(1041267, lean.Cliloc); - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs deleted file mode 100644 index b360a2726..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionOplTests.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Server.Commands; -using Server.Items; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -[Collection("Sequential UOContent Tests")] -public class ObjectIntrospectionOplTests -{ - [Fact] - public void ExtractOpl_captures_the_runebook_name_cliloc() - { - // Runebook.LabelNumber is 1041267 ("runebook") — it appears as an OPL line. - var opl = ObjectIntrospection.ExtractOpl(typeof(Runebook)); - Assert.Contains(opl, line => line.Cliloc == 1041267); - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs deleted file mode 100644 index 1eba2a5bb..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectIntrospectionPropertiesTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Server.Commands; -using Server.Items; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -[Collection("Sequential UOContent Tests")] -public class ObjectIntrospectionPropertiesTests -{ - [Fact] - public void ExtractProperties_includes_inherited_item_command_properties() - { - var props = ObjectIntrospection.ExtractProperties(typeof(Runebook)); - - var hue = Assert.Single(props, p => p.Name == "Hue"); - Assert.Equal("int", hue.Type); - - var lootType = Assert.Single(props, p => p.Name == "LootType"); - Assert.NotNull(lootType.EnumValues); - Assert.Contains("Blessed", lootType.EnumValues); - } -} diff --git a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs b/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs deleted file mode 100644 index f24216df8..000000000 --- a/Projects/UOContent.Tests/Tests/Commands/Objects/ObjectNamingTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using Server.Commands; -using Xunit; - -namespace UOContent.Tests.Commands.Objects; - -public class ObjectNamingTests -{ - [Theory] - [InlineData("Items.Skill Items.Magical", "items.skill-items.magical")] - [InlineData("Items.Weapons.Swords", "items.weapons.swords")] - [InlineData("Mobiles.Uncategorized", "mobiles.uncategorized")] - public void ChunkKey_lowercases_and_replaces_spaces(string category, string expected) - { - Assert.Equal(expected, ObjectNaming.ChunkKey(category)); - } - - [Theory] - [InlineData(typeof(int), "int")] - [InlineData(typeof(bool), "bool")] - [InlineData(typeof(string), "string")] - [InlineData(typeof(double), "double")] - [InlineData(typeof(int?), "int?")] - [InlineData(typeof(Server.Items.WeaponQuality), "WeaponQuality")] - public void FriendlyTypeName_maps_primitives_and_keeps_enum_names(Type t, string expected) - { - Assert.Equal(expected, ObjectNaming.FriendlyTypeName(t)); - } -} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs new file mode 100644 index 000000000..5166124e5 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchPagingTests.cs @@ -0,0 +1,17 @@ +using Server.Engines.AdvancedSearch; +using Xunit; + +namespace UOContent.Tests; + +public class AdvancedSearchPagingTests +{ + [Theory] + [InlineData(20, 0, 18, 18)] // full first page + [InlineData(20, 18, 18, 2)] // partial last page -> 2 visible (bug rendered 0 in descending) + [InlineData(5, 0, 18, 5)] + [InlineData(0, 0, 18, 0)] + public void VisibleCount_IsCorrect(int total, int from, int max, int expected) + { + Assert.Equal(expected, AdvancedSearchGump.VisibleCount(total, from, max)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs new file mode 100644 index 000000000..ea14f3cf2 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchTypesTests.cs @@ -0,0 +1,27 @@ +using Server; +using Server.Engines.AdvancedSearch; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class AdvancedSearchTypesTests +{ + [Fact] + public void CompareValues_Poison_ReferenceTypeParsedViaTypes() + { + PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set + + // Poison is a reference type implementing ISpanParsable; it can't use the compile-time span + // path and routes through the shared Server.Types converter. Poison.Parse returns the + // registered singleton, so "= Lethal" is a reference-equality match — this is the case that + // previously compared a Poison against the raw string and always failed. + var prop = Poison.Lethal; + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lethal", "=")); + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lesser", "=")); + + var ex = Record.Exception(() => + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "notapoison", "="))); + Assert.Null(ex); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs new file mode 100644 index 000000000..9176fae4a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchUtilitiesTests.cs @@ -0,0 +1,119 @@ +using System; +using Server; +using Server.Engines.AdvancedSearch; +using Xunit; + +namespace UOContent.Tests; + +public class AdvancedSearchUtilitiesTests +{ + [Theory] + [InlineData("abc")] // not a number -> was FormatException + [InlineData("99999999999")] // overflows int -> was OverflowException + [InlineData("0xZZ")] // bad hex -> was FormatException + public void CompareValues_BadNumeric_ReturnsFalse_DoesNotThrow(string value) + { + var ex = Record.Exception(() => + { + var result = AdvancedSearchUtilities.CompareValues(typeof(int), 5, value, ">"); + Assert.False(result); + }); + Assert.Null(ex); + } + + [Theory] + [InlineData("Bogus")] // not a member -> was ArgumentException + [InlineData("onehandedxyz")] // not a member, even case-insensitively -> was ArgumentException + public void CompareValues_BadEnum_ReturnsFalse_DoesNotThrow(string value) + { + var ex = Record.Exception(() => + { + var result = AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, value, "="); + Assert.False(result); + }); + Assert.Null(ex); + } + + [Fact] + public void CompareValues_ValidEnum_IgnoresCase() + { + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, "onehanded", "=")); + } + + [Theory] + // leaf value is "T"/"F"; evalLeaf returns leaf=="T" + [InlineData("T", true)] + [InlineData("F", false)] + [InlineData("F@F|T", true)] // (F&&F)||T = T (buggy code gave F&&(F||T)=F) + [InlineData("T|F@F", true)] // T||(F&&F) = T (buggy code gave (T||F)&&F=F) + [InlineData("T@F", false)] + [InlineData("T@T", true)] + [InlineData("F|F", false)] + public void EvaluateBoolean_Precedence(string expr, bool expected) + { + // State is unused here; the leaf evaluator just checks the span equals "T". + var result = AdvancedSearchUtilities.EvaluateBoolean(expr, 0, static (_, leaf) => leaf.SequenceEqual("T")); + Assert.Equal(expected, result); + } + + [Fact] + public void CompareValues_ReferenceType_EqualityByString_NoThrow() + { + // A reference-typed property (e.g. RootParent name-ish) compared with "=" should not throw, + // and ordering operators must return false rather than throwing. + var ex = Record.Exception(() => + { + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(object), new object(), "whatever", ">")); + }); + Assert.Null(ex); + } + + [Fact] + public void CompareValues_TimeSpan_ParsesViaSpanParsable() + { + // TimeSpan is not IConvertible, so the old Convert.ChangeType fallback threw and silently + // returned no-match. ISpanParsable parses it correctly. + var prop = TimeSpan.FromMinutes(5); + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:05:00", "=")); + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:10:00", "=")); + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:01:00", ">")); + } + + [Fact] + public void CompareValues_TimeSpan_BadInput_ReturnsFalse_NoThrow() + { + var ex = Record.Exception(() => + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), TimeSpan.Zero, "notaspan", "="))); + Assert.Null(ex); + } + + [Fact] + public void CompareValues_Guid_ValueTypeParsedViaTypes() + { + // Guid is a value type not named by the hot paths; it's parsed via Types (IParsable) and + // compared by value. + var g = Guid.Parse("00000000-0000-0000-0000-000000000001"); + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000001", "=")); + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000002", "=")); + } + + // A reference type with a legacy RunUO-style static Parse(string) and NO IParsable<> interface — + // the Faction/Town shape. Types must still discover its Parse by reflection. + private sealed class LegacyParseType + { + public string Value { get; private init; } + public static LegacyParseType Parse(string s) => new() { Value = s }; + public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value; + public override int GetHashCode() => Value?.GetHashCode() ?? 0; + } + + [Fact] + public void CompareValues_LegacyParseString_ParsedViaTypes() + { + // Pre-IParsable types (only a static Parse(string)) must still be searchable: Types binds the + // legacy Parse by reflection, so we compare against a real parsed instance, not the raw text. + var prop = LegacyParseType.Parse("alpha"); + Assert.True(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "alpha", "=")); + Assert.False(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "beta", "=")); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs new file mode 100644 index 000000000..2bfe94af8 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/AdvancedSearch/AdvancedSearchWorkerTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Concurrent; +using Server; +using Server.Engines.AdvancedSearch; +using Server.Items; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class AdvancedSearchWorkerTests +{ + // An item whose property test path will throw when evaluated. + private sealed class ThrowingItem : Item + { + public ThrowingItem() : base(0x1) { } + public ThrowingItem(Serial s) : base(s) { } + public string Boom => throw new InvalidOperationException("boom"); + } + + [Fact] + public void Worker_FilterThrows_DoesNotEscape_ReturnsNoMatch() + { + var worker = new AdvancedSearchThreadWorker(); + var results = new ConcurrentQueue(); + var ignore = new ConcurrentQueue(); + var filter = new AdvancedSearchFilter + { + FilterPropertyTest = true, + PropertyTest = "Boom=1", // reflection GetValue -> throws + }; + + var item = new ThrowingItem(); + + try + { + worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); + worker.Push(item); + worker.Sleep(); // drains; must not crash the test process + + Assert.Empty(results); + } + finally + { + item.Delete(); + worker.Exit(); + } + } + + [Fact] + public void Worker_DeletedEntity_IsSkipped() + { + var worker = new AdvancedSearchThreadWorker(); + var results = new ConcurrentQueue(); + var ignore = new ConcurrentQueue(); + var filter = new AdvancedSearchFilter(); // no filters -> everything matches + + var item = new Item(0x1); + item.Delete(); + + try + { + worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore); + worker.Push(item); + worker.Sleep(); + + Assert.Empty(results); + } + finally + { + worker.Exit(); + } + } + + [Fact] + public void DoSearch_IsGuarded_AgainstReentry() + { + // White-box: flip the guard, assert a second entry is rejected, then clear. + // _searchInProgress is process-global static state; release it in finally so a + // failed assert here can't leak the guard into other tests. + Assert.False(AdvancedSearchGump.IsSearchInProgress); + Assert.True(AdvancedSearchGump.TryBeginSearch()); // acquires + try + { + Assert.False(AdvancedSearchGump.TryBeginSearch()); // rejected + } + finally + { + AdvancedSearchGump.EndSearch(); // releases + } + + Assert.False(AdvancedSearchGump.IsSearchInProgress); + } +} diff --git a/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs b/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs new file mode 100644 index 000000000..2416055b1 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Factions/FactionRankTests.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using Server; +using Server.Factions; +using Xunit; + +namespace UOContent.Tests; + +/// +/// PlayerState.Rank is read from GetProperties, so it must stay a plain field read. These pin what +/// that requires: the rank is never null, and it is correct without anyone having read it first. +/// +[Collection("Sequential UOContent Tests")] +public class FactionRankTests +{ + // The faction ctor builds its own Definition, so no world state is needed. + private static Faction NewFaction() => new CouncilOfMages(); + + private static PlayerState AddMember(Faction faction, List owner) + { + var state = new PlayerState(new Mobile(), faction, owner); + owner.Add(state); + return state; + } + + [Fact] + public void Rank_IsPopulatedBeforeAnythingReadsIt() + { + var faction = NewFaction(); + var state = new PlayerState(new Mobile(), faction, []); + + // Nothing recomputes on read, so the ctor must leave a usable value or Rank.Title NREs. + Assert.NotNull(state.Rank); + Assert.NotNull(state.Rank.Title); + } + + [Fact] + public void Rank_IsTheLowestRank_ForAnUnrankedMember() + { + var faction = NewFaction(); + var owner = new List(); + var a = AddMember(faction, owner); + var b = AddMember(faction, owner); + + a.UpdateRank(); + b.UpdateRank(); + + var lowest = faction.Definition.Ranks[^1]; + + Assert.Equal(lowest.Rank, a.Rank.Rank); + Assert.Equal(lowest.Rank, b.Rank.Rank); + } + + [Fact] + public void SettingRankIndex_UpdatesRankWithoutAnyoneReadingIt() + { + var faction = NewFaction(); + var owner = new List(); + var top = AddMember(faction, owner); + var bottom = AddMember(faction, owner); + + faction.ZeroRankOffset = 2; + + top.RankIndex = 0; + bottom.RankIndex = 1; + + // No read triggered these, yet the ordering is reflected. + Assert.True(top.Rank.Rank > bottom.Rank.Rank); + } + + [Fact] + public void ReadingRank_IsStableAndSideEffectFree() + { + var faction = NewFaction(); + var owner = new List(); + var state = AddMember(faction, owner); + + faction.ZeroRankOffset = 1; + state.RankIndex = 0; + + var first = state.Rank; + var second = state.Rank; + + Assert.Same(first, second); + } + + [Fact] + public void RankIndexOutOfSyncWithZeroRankOffset_StillResolvesARank() + { + var faction = NewFaction(); + var owner = new List(); + var a = AddMember(faction, owner); + AddMember(faction, owner); + + // A negative percent used to match no rank at all, leaving Rank null. + faction.ZeroRankOffset = 1; + a.RankIndex = 5; + + Assert.NotNull(a.Rank); + } +} diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs index 998e4c537..fd3dafc56 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/DynamicTestGump.cs @@ -6,6 +6,8 @@ public class DynamicTestGump : DynamicGump { private readonly string _petName; + public bool HasVisualElementsForTest => HasVisualElements; + public DynamicTestGump(string petName) : base(50, 50) { _petName = petName; diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs new file mode 100644 index 000000000..b99de1700 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/EmptyTestGumps.cs @@ -0,0 +1,40 @@ +using Server.Gumps; + +namespace Server.Tests.Gumps; + +public sealed class EmptyLegacyTestGump : Gump +{ + public bool HasVisualElementsForTest => HasVisualElements; + + public EmptyLegacyTestGump() : base(0, 0) + { + } +} + +public sealed class EmptyDynamicTestGump : DynamicGump +{ + public bool HasVisualElementsForTest => HasVisualElements; + + public EmptyDynamicTestGump() : base(0, 0) + { + } + + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + builder.AddPage(); + } +} + +public sealed class EmptyStaticTestGump : StaticGump +{ + public bool HasVisualElementsForTest => HasVisualElements; + + public EmptyStaticTestGump() : base(0, 0) + { + } + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + builder.SetNoClose(); + } +} diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs index 668d7e8ae..e45514c38 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/LegacyTestGump.cs @@ -4,6 +4,8 @@ namespace Server.Tests.Gumps; public sealed class LegacyTestGump : Gump { + public bool HasVisualElementsForTest => HasVisualElements; + public LegacyTestGump(string petName) : base(50, 50) { Serial = (Serial)0x123; diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs index ea67720f5..66d80c963 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestGumps/StaticTestGump.cs @@ -4,6 +4,8 @@ namespace Server.Tests.Gumps; public class StaticTestGump : StaticGump { + public bool HasVisualElementsForTest => HasVisualElements; + public StaticTestGump() : base(50, 50) { Serial = (Serial)0x123; diff --git a/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs b/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs index 7cd8e67d1..804408fc7 100644 --- a/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs +++ b/Projects/UOContent.Tests/Tests/Gumps/TestLayoutGumps.cs @@ -73,6 +73,32 @@ public class TestLayoutGumps AssertThat.Equal(writer.Span, packet); } + [Fact] + public void TestEmptyGumpsHaveNoVisualElements() + { + Assert.False(Compile(new EmptyLegacyTestGump()).HasVisualElementsForTest); + Assert.False(Compile(new EmptyDynamicTestGump()).HasVisualElementsForTest); + Assert.False(Compile(new EmptyStaticTestGump()).HasVisualElementsForTest); + Assert.False(Compile(new EmptyStaticTestGump()).HasVisualElementsForTest); + } + + [Fact] + public void TestVisibleGumpsHaveVisualElements() + { + Assert.True(Compile(new LegacyTestGump("Test")).HasVisualElementsForTest); + Assert.True(Compile(new DynamicTestGump("Test")).HasVisualElementsForTest); + Assert.True(Compile(new StaticTestGump()).HasVisualElementsForTest); + Assert.True(Compile(new StaticTestGump()).HasVisualElementsForTest); + } + + private static T Compile(T gump) where T : BaseGump + { + var buffer = GC.AllocateUninitializedArray(512); + var writer = new SpanWriter(buffer); + gump.Compile(ref writer); + return gump; + } + private static void InternalTestStaticGump(ReadOnlySpan expectedLayout, StaticGump staticGump, string[] strings) where T : StaticGump { diff --git a/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs new file mode 100644 index 000000000..f9b8763e7 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/TreasureChests/TreasureMapChestLiftTests.cs @@ -0,0 +1,111 @@ +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class TreasureMapChestLiftTests +{ + // Coordinates chosen to avoid overlap with Tracking (1000-4000, 1000-4000) and + // DetectHidden (1000-2400, 500) test areas. + + [Fact] + public void PartialLift_MarksSplitRemainderAsLifted() + { + using var rng = new PredictableRandom(10); // RandomDouble() = 0.5, no spawn roll fires + var map = Map.Felucca; + var location = new Point3D(5000, 600, 0); + var player = CreatePlayerMobile(map, location); + var chest = new TreasureMapChest(1); + + try + { + chest.MoveToWorld(location, map); + chest.Locked = false; + + var gold = FindGold(chest, null); + Assert.NotNull(gold); + + player.Lift(gold, 1, out var rejected, out _); + Assert.False(rejected); + + // The stack split re-adds the remainder as a brand-new item. It must count as + // already lifted, otherwise every 1-coin pull grants a fresh guardian spawn roll. + var remainder = FindGold(chest, gold); + Assert.NotNull(remainder); + Assert.Contains(remainder, chest.Lifted); + Assert.Contains(gold, chest.Lifted); + } + finally + { + player.Holding?.Delete(); + player.Delete(); + chest.Delete(); + } + } + + [Fact] + public void ItemAddedAfterFill_IsMarkedLifted() + { + using var rng = new PredictableRandom(10); + var chest = new TreasureMapChest(1); + var packed = new Gold(500); + + try + { + // Anything entering the chest after the initial fill (packed-back gold, split + // remainders, GM drops) was never part of the original loot and must not + // grant spawn rolls when lifted back out. + chest.DropItem(packed); + + Assert.Contains(packed, chest.Lifted); + } + finally + { + chest.Delete(); + } + } + + [Fact] + public void OriginalFillLoot_IsNotMarkedLifted() + { + using var rng = new PredictableRandom(10); + var chest = new TreasureMapChest(1); + + try + { + // The original loot must stay roll-eligible for its first lift. + Assert.True(chest.Lifted == null || chest.Lifted.Count == 0); + } + finally + { + chest.Delete(); + } + } + + private static Gold FindGold(TreasureMapChest chest, Gold except) + { + var items = chest.Items; + + for (var i = 0; i < items.Count; i++) + { + if (items[i] is Gold gold && gold != except) + { + return gold; + } + } + + return null; + } + + private static PlayerMobile CreatePlayerMobile(Map map, Point3D location) + { + var mobile = new PlayerMobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.MoveToWorld(location, map); + return mobile; + } +} diff --git a/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs new file mode 100644 index 000000000..7d9243a29 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Mobiles/AI/MoveSpeedTests.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Mobiles.AI; + +// Pins the CurrentMoveSpeed classification (verbatim active/passive maps to the matching +// move value; bespoke stays fused), SetSpeed's one-clock guarantee, and the v22 tail. +[Collection("Sequential UOContent Tests")] +public class MoveSpeedTests : IDisposable +{ + // Delete spawned stubs so they don't linger in the shared static World. + private readonly List _created = new(); + + public void Dispose() + { + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private sealed class SpeedStub : BaseCreature + { + // Stands in for the npc-speeds table (unconfigured in the test fixture). + public double TableActiveMove; + public double TablePassiveMove; + + public SpeedStub() : base(AIType.AI_Animal) => Body = 0xC9; + + public SpeedStub(Serial serial) : base(serial) => Body = 0xC9; + + public override void GetSpeeds(out double activeSpeed, out double passiveSpeed) + { + activeSpeed = 0.3; + passiveSpeed = 0.6; + } + + public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + activeMoveSpeed = TableActiveMove; + passiveMoveSpeed = TablePassiveMove; + } + } + + private SpeedStub NewCreature() + { + var bc = new SpeedStub(); + _created.Add(bc); + return bc; + } + + [Fact] + public void MoveSpeeds_InheritThinkValues_ByDefault() + { + var bc = NewCreature(); + + Assert.Equal(0.3, bc.ActiveMoveSpeed); + Assert.Equal(0.6, bc.PassiveMoveSpeed); + Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_ResolvesPerMode_WhenOverridden() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // SetSpeed left the creature passive; the think clock is untouched. + Assert.Equal(0.6, bc.CurrentSpeed); + Assert.Equal(0.9, bc.CurrentMoveSpeed); + + bc.SetCurrentSpeedToActive(); + Assert.Equal(0.3, bc.CurrentSpeed); + Assert.Equal(0.45, bc.CurrentMoveSpeed); + } + + [Fact] + public void CurrentMoveSpeed_BespokePace_StaysFused() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + // Neither think value verbatim, so both clocks run it. + bc.CurrentSpeed = 0.11; + Assert.Equal(0.11, bc.CurrentMoveSpeed); + } + + [Fact] + public void SetSpeed_ClearsMoveOverrides() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.SetSpeed(0.2, 0.4); + + Assert.Equal(0.2, bc.ActiveMoveSpeed); + Assert.Equal(0.4, bc.PassiveMoveSpeed); + } + + [Fact] + public void NonPositiveMoveSpeed_ClearsThatOverride() + { + var bc = NewCreature(); + bc.SetMoveSpeed(0.45, 0.9); + + bc.ActiveMoveSpeed = 0; + + Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again + Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched + } + + [Fact] + public void ScaleMoveSpeed_ScalesOverrides_LeavesInheritAlone() + { + var bc = NewCreature(); + bc.ActiveMoveSpeed = 0.6; // passive left inheriting + + bc.ScaleMoveSpeed(1.0 / 1.2); + + Assert.Equal(0.5, bc.ActiveMoveSpeed); + Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar + } + + [Fact] + public void Herding_DrivesMoveClock_ThinkUntouched() + { + var bc = NewCreature(); // think 0.3/0.6, passive + bc.SetMoveSpeed(0.45, 1.05); + + bc.TargetLocation = new Point2D(10, 10); + + Assert.Equal(0.6, bc.CurrentSpeed); // think clock unaffected by herding + Assert.Equal(0.3, bc.CurrentMoveSpeed); // fixed herding pace, not 1.05 + + bc.TargetLocation = null; + Assert.Equal(1.05, bc.CurrentMoveSpeed); + } + + [Fact] + public void SnapSpeedsToTable_UndoesScalingDrift_KeepsTunedValues() + { + var bc = NewCreature(); + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + bc.SetMoveSpeed(0.45, 0.9); + + // 0.45 and 0.9 do not survive /1.2 then *1.2 bit-exactly. + bc.ScaleMoveSpeed(1.0 / 1.2); + bc.ScaleMoveSpeed(1.2); + Assert.NotEqual(0.45, bc.ActiveMoveSpeed); + + bc.SnapSpeedsToTable(); + Assert.Equal(0.45, bc.ActiveMoveSpeed); + Assert.Equal(0.9, bc.PassiveMoveSpeed); + + // A hand-tuned value is nowhere near the epsilon and must keep. + bc.SetMoveSpeed(0.7, 0.9); + bc.SnapSpeedsToTable(); + Assert.Equal(0.7, bc.ActiveMoveSpeed); + } + + [Fact] + public void Migration_MatchingThinkSpeeds_AdoptTableMoveValues() + { + var bc = NewCreature(); // think 0.3/0.6, matching its table entry + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + + bc.MigrateMoveSpeeds(); + + Assert.Equal(0.45, bc.ActiveMoveSpeed); + Assert.Equal(0.9, bc.PassiveMoveSpeed); + } + + [Fact] + public void Migration_TunedThinkSpeeds_KeepInheriting() + { + var bc = NewCreature(); + bc.SetSpeed(0.35, 0.6); // hand-tuned: no longer matches the table entry + bc.TableActiveMove = 0.45; + bc.TablePassiveMove = 0.9; + + bc.MigrateMoveSpeeds(); + + Assert.Equal(0.35, bc.ActiveMoveSpeed); + Assert.Equal(0.6, bc.PassiveMoveSpeed); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MoveSpeedOverrides_SurviveSerialization(bool overridden) + { + var bc = NewCreature(); + if (overridden) + { + bc.SetMoveSpeed(0.45, 0.9); + } + + var writer = new BufferWriter(true); + bc.Serialize(writer); + + var buffer = new byte[writer.Position]; + writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer); + + var copy = new SpeedStub(World.NewMobile); + _created.Add(copy); + var reader = new BufferReader(buffer); + copy.Deserialize(reader); + + // The v22 tail is the last block; exact consumption catches any offset mistake. + Assert.Equal(buffer.Length, reader.Position); + Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed); + Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed); + } +} diff --git a/Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs b/Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs new file mode 100644 index 000000000..3be9d9a0d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Multis/SpreadsheetTests.cs @@ -0,0 +1,114 @@ +using System; +using System.IO; +using Server.Multis; +using Xunit; + +namespace UOContent.Tests; + +public class SpreadsheetTests : IDisposable +{ + // misc.txt's shape: 13 columns, the last being the Comment the client never reads. + private const string Types = "int\tint\tint\tint\tint\tint\tint\tint\tint\tint\tint\tint\tstring"; + + private const string Names = + "Category\tStyle\tTID\tPiece1\tPiece2\tPiece3\tPiece4\tPiece5\tPiece6\tPiece7\tPiece8\tFeatureMask\tComment"; + + private readonly string _path = Path.Combine(Path.GetTempPath(), $"muo-sheet-{Guid.NewGuid():N}.txt"); + + public void Dispose() + { + if (File.Exists(_path)) + { + File.Delete(_path); + } + } + + private Spreadsheet Write(params string[] rows) + { + var lines = new string[rows.Length + 2]; + lines[0] = Types; + lines[1] = Names; + rows.CopyTo(lines, 2); + + File.WriteAllLines(_path, lines); + return new Spreadsheet(_path); + } + + [Fact] + public void RowMissingTrailingCommentIsStillRead() + { + // An empty Comment written without a trailing tab leaves the row one field short. + var ss = Write("0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t8"); + + var record = Assert.Single(ss.Records); + + Assert.Equal(44, record.GetInt32(ss.GetColumnID("Piece1"))); + Assert.Equal(29, record.GetInt32(ss.GetColumnID("Piece8"))); + Assert.Equal(8, record.GetInt32(ss.GetColumnID("FeatureMask"))); + } + + [Fact] + public void ShortRowDoesNotDropLaterRows() + { + var ss = Write( + "0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0", + "0\t1\t1060057\t45\t0\t0\t0\t0\t0\t0\t0\t0\tFieldstone Arches" + ); + + Assert.Equal(2, ss.Records.Length); + Assert.Equal(44, ss.Records[0].GetInt32(ss.GetColumnID("Piece1"))); + Assert.Equal(45, ss.Records[1].GetInt32(ss.GetColumnID("Piece1"))); + } + + [Fact] + public void TabOnlySeparatorLinesAreNotMistakenForHeaderRows() + { + // The retail client's doors.txt separates its header rows this way. + var separator = new string('\t', 12); + + File.WriteAllLines( + _path, + [ + Types, + separator, + Names, + separator, + "0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0\tFieldstone Archways" + ] + ); + + var ss = new Spreadsheet(_path); + + Assert.Equal(3, ss.GetColumnID("Piece1")); + Assert.Equal(11, ss.GetColumnID("FeatureMask")); + + var record = Assert.Single(ss.Records); + Assert.Equal(44, record.GetInt32(ss.GetColumnID("Piece1"))); + } + + [Fact] + public void MissingHeaderRowsThrowsInsteadOfNullReference() + { + File.WriteAllLines(_path, [Types]); + + Assert.Throws(() => new Spreadsheet(_path)); + } + + [Fact] + public void HeaderWithFewerNamesThanTypesIsTolerated() + { + File.WriteAllLines( + _path, + [ + Types, + "Category\tStyle\tTID\tPiece1", + "0\t0\t1060056\t44\t0\t41\t40\t42\t0\t43\t29\t0\tFieldstone Archways" + ] + ); + + var ss = new Spreadsheet(_path); + + Assert.Equal(44, ss.Records[0].GetInt32(ss.GetColumnID("Piece1"))); + Assert.Equal(-1, ss.GetColumnID("FeatureMask")); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs b/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs new file mode 100644 index 000000000..ae926794d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/AutoDenylist/AutoDenylistTests.cs @@ -0,0 +1,206 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: AutoDenylistTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using Server.Network; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.AutoDenylists; + +// Static store, so every test resets it first and none may run alongside another. +// Addresses come from TEST-NET-2 (198.51.100.0/24). +[Collection("Sequential UOContent Tests")] +public class AutoDenylistTests +{ + private const long DurationMs = 900_000; // 15 minutes, the shipped default + private const long Now = 1_000_000; + + private static void Reset(bool enabled = true, int maxEntries = 1024) => + AutoDenylist.LoadForTesting(enabled, DurationMs, maxEntries); + + [Fact] + public void Behavioral_detection_is_held_then_lapses() + { + Reset(); + var ip = IPAddress.Parse("198.51.100.10"); + + Assert.True(AutoDenylist.Hold(ip, BanReasons.InvalidSeed, Now)); + + Assert.True(AutoDenylist.IsDenied(ip, Now)); + Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs - 1)); + + // Expiry is decided on read, so the hold lapses without waiting for a sweep. + Assert.False(AutoDenylist.IsDenied(ip, Now + DurationMs)); + } + + [Fact] + public void Manual_and_list_verdicts_are_not_held() + { + Reset(); + var manual = IPAddress.Parse("198.51.100.11"); + var listed = IPAddress.Parse("198.51.100.12"); + + // A manual ban belongs in the firewall; a blocklist match is enforced by the blocklist's own filter. + Assert.False(AutoDenylist.Hold(manual, BanReasons.Manual, Now)); + Assert.False(AutoDenylist.Hold(listed, BanReasons.Blocklist, Now)); + + Assert.False(AutoDenylist.IsDenied(manual, Now)); + Assert.False(AutoDenylist.IsDenied(listed, Now)); + Assert.Equal(0, AutoDenylist.Count); + } + + // Not refreshed on purpose: it is what keeps insertion order equal to expiry order, so retiring lapsed + // entries costs the number expiring instead of the number held. A flooder whose hold lapses trips the + // rate limiter on its next attempt -- which runs ahead of the connection filters -- and is held again. + [Fact] + public void Repeat_detection_does_not_extend_the_hold() + { + Reset(); + var ip = IPAddress.Parse("198.51.100.13"); + + AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now); + AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1); + + Assert.Equal(1, AutoDenylist.Count); // no duplicate + Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs - 1)); + Assert.False(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // lapses from the FIRST detection + } + + // The ring carries the expiry and the set carries membership; if they ever disagree, an address is + // either denied forever or retired early. + [Fact] + public void Ring_and_set_stay_in_step() + { + Reset(maxEntries: 4); + + for (var i = 0; i < 8; i++) + { + AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{70 + i}"), BanReasons.InvalidSeed, Now); + } + + Assert.Equal(4, AutoDenylist.Count); + Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount); + + AutoDenylist.Release(IPAddress.Parse("198.51.100.71")); + Assert.Equal(3, AutoDenylist.Count); + Assert.Equal(AutoDenylist.Count, AutoDenylist.RingCount); + + AutoDenylist.Drain(Now + DurationMs + 1); + Assert.Equal(0, AutoDenylist.Count); + Assert.Equal(0, AutoDenylist.RingCount); + } + + // The ring grows in doublings but is capped at maxEntries, which is not a power of two. Filling exactly + // to it must land on the last slot rather than off the end. + [Fact] + public void Ring_fills_exactly_to_a_non_power_of_two_cap() + { + Reset(maxEntries: 100); + + for (var i = 0; i < 120; i++) + { + AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{i}"), BanReasons.InvalidSeed, Now); + } + + Assert.Equal(100, AutoDenylist.Count); + Assert.Equal(100, AutoDenylist.RingCount); + + // And the whole ring still drains, so no slot was stranded by a wrapped write. + AutoDenylist.Drain(Now + DurationMs + 1); + Assert.Equal(0, AutoDenylist.Count); + Assert.Equal(0, AutoDenylist.RingCount); + } + + // Releasing leaves no ring record behind, so a re-detection is not retired by the old one. + [Fact] + public void Release_then_re_hold_is_not_retired_by_the_stale_record() + { + Reset(); + var ip = IPAddress.Parse("198.51.100.15"); + + AutoDenylist.Hold(ip, BanReasons.RateLimit, Now); + AutoDenylist.Release(ip); + + var later = Now + DurationMs - 1; + AutoDenylist.Hold(ip, BanReasons.RateLimit, later); + + // The first hold's expiry has passed; the second must survive it. + Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); + Assert.Equal(1, AutoDenylist.RingCount); + } + + [Fact] + public void Release_drops_the_hold_immediately() + { + Reset(); + var ip = IPAddress.Parse("198.51.100.14"); + + AutoDenylist.Hold(ip, BanReasons.RateLimit, Now); + AutoDenylist.Release(ip); + + Assert.False(AutoDenylist.IsDenied(ip, Now)); + } + + [Fact] + public void Cap_is_enforced_so_a_distinct_source_flood_cannot_grow_it() + { + Reset(maxEntries: 4); + + for (var i = 0; i < 10; i++) + { + AutoDenylist.Hold(IPAddress.Parse($"198.51.100.{100 + i}"), BanReasons.InvalidSeed, Now); + } + + Assert.Equal(4, AutoDenylist.Count); + + // The first four are still held; the rest were disconnected by their gate but not tracked. + Assert.True(AutoDenylist.IsDenied(IPAddress.Parse("198.51.100.100"), Now)); + Assert.False(AutoDenylist.IsDenied(IPAddress.Parse("198.51.100.109"), Now)); + } + + [Fact] + public void Reaching_the_cap_reclaims_lapsed_entries_first() + { + Reset(maxEntries: 2); + + AutoDenylist.Hold(IPAddress.Parse("198.51.100.20"), BanReasons.InvalidSeed, Now); + AutoDenylist.Hold(IPAddress.Parse("198.51.100.21"), BanReasons.InvalidSeed, Now); + + // Once those two have lapsed, a new detection sweeps them out rather than being refused. + var later = Now + DurationMs + 1; + Assert.True(AutoDenylist.Hold(IPAddress.Parse("198.51.100.22"), BanReasons.InvalidSeed, later)); + Assert.True(AutoDenylist.IsDenied(IPAddress.Parse("198.51.100.22"), later)); + } + + [Fact] + public void Disabled_store_denies_nobody() + { + Reset(enabled: false); + var ip = IPAddress.Parse("198.51.100.30"); + + Assert.False(AutoDenylist.Hold(ip, BanReasons.InvalidSeed, Now)); + Assert.False(AutoDenylist.IsDenied(ip, Now)); + } + + [Fact] + public void Null_address_is_handled() + { + Reset(); + + Assert.False(AutoDenylist.Hold(null, BanReasons.InvalidSeed, Now)); + Assert.False(AutoDenylist.IsDenied(null, Now)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs new file mode 100644 index 000000000..b38364373 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/BanExemptionsTests.cs @@ -0,0 +1,122 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanExemptionsTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using System.Text; +using Server.Network; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Exemptions; + +// Addresses come from TEST-NET-1 (192.0.2.0/24) to stay clear of the other ban test classes if xUnit runs +// them concurrently. +public class BanExemptionsTests +{ + private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10"); + private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11"); + + private static void WithManualAllowlist(string contents) => + ManualAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _)); + + private static void WithEmptyManualAllowlist() => ManualAllowlist.LoadForTesting(BlocklistSnapshot.Empty); + + [Fact] + public void Manual_allowlist_exempts_behavioral_contributions() + { + WithManualAllowlist("192.0.2.10"); + + // Subtracting from the blocklist does nothing for behavioural detections, which never consult it. + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.ForeignProtocol, NeverCalled)); + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.SilentConnect, NeverCalled)); + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.InvalidSeed, NeverCalled)); + } + + [Fact] + public void Manual_allowlist_covers_cidr_entries() + { + // Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here. + WithManualAllowlist("192.0.2.0/24"); + + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); + Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled)); + Assert.False(BanExemptions.IsExempt(IPAddress.Parse("192.0.3.1"), BanReasons.RateLimit, AlwaysFalse)); + } + + [Fact] + public void Manual_bans_are_never_exempt_even_when_allowlisted() + { + WithManualAllowlist("192.0.2.10"); + + // An explicit decision outranks the operator's own carve-out, and must not cost a strike. + Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled)); + } + + [Fact] + public void Unopted_reasons_are_never_exempt() + { + WithManualAllowlist("192.0.2.10"); + + Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled)); + Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled)); + } + + [Fact] + public void Manual_allowlist_does_not_spend_the_earned_lists_strikes() + { + WithManualAllowlist("192.0.2.10"); + + // Unconditional, so the revocable list must not be consulted -- that would burn a strike. + Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); + } + + [Fact] + public void Falls_through_to_the_login_allowlist_when_not_file_listed() + { + WithEmptyManualAllowlist(); + + var consulted = 0; + + var result = BanExemptions.IsExempt( + _unlisted, + BanReasons.RateLimit, + (_, _) => + { + consulted++; + return true; + } + ); + + Assert.True(result); + Assert.Equal(1, consulted); + } + + [Fact] + public void Null_address_is_never_exempt() + { + WithEmptyManualAllowlist(); + + Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled)); + } + + private static bool NeverCalled(IPAddress address, string reason) + { + Assert.Fail("The login allowlist must not be consulted once the answer is already decided."); + return false; + } + + private static bool AlwaysFalse(IPAddress address, string reason) => false; +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs new file mode 100644 index 000000000..3d81c8d87 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistConfigurationTests.cs @@ -0,0 +1,75 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistConfigurationTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Text.Json; +using Server.Json; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans.Blocklist; + +public class BlocklistConfigurationTests +{ + // Locks the JsonConfig casing contract: JsonConfig's options are case-SENSITIVE, so every settings + // member must carry an explicit [JsonPropertyName("camelCase")] or it silently binds nothing. + [Fact] + public void BlocklistSettings_RoundTripsThroughJsonConfig() + { + var original = new BlocklistSettings + { + Enabled = true, + File = "D:/shared/ip-blocklist.txt", + ReloadInterval = TimeSpan.FromMinutes(5), + ReportHits = false, + BanDuration = TimeSpan.FromHours(2), + PromoteSuppression = TimeSpan.FromSeconds(30) + }; + + var json = JsonConfig.Serialize(original); + + Assert.Contains("\"enabled\"", json); + Assert.Contains("\"file\"", json); + Assert.Contains("\"reloadInterval\"", json); + Assert.Contains("\"reportHits\"", json); + Assert.Contains("\"banDuration\"", json); + Assert.Contains("\"promoteSuppression\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.Enabled, restored.Enabled); + Assert.Equal(original.File, restored.File); + Assert.Equal(original.ReloadInterval, restored.ReloadInterval); + Assert.Equal(original.ReportHits, restored.ReportHits); + Assert.Equal(original.BanDuration, restored.BanDuration); + Assert.Equal(original.PromoteSuppression, restored.PromoteSuppression); + } + + // The point of the flag: a shard that never opts in must not start the reload poll. + [Fact] + public void Blocklist_is_off_by_default() + { + Assert.False(new BlocklistSettings().Enabled); + } + + // The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves + // without the other, a shard silently enforces nothing. + [Fact] + public void Default_file_matches_the_generator_output_path() + { + Assert.Equal("Configuration/ip-blocklist.txt", new BlocklistSettings().File); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs new file mode 100644 index 000000000..1b7a7772c --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFileTests.cs @@ -0,0 +1,85 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistFileTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; +using System.Net; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans.Blocklist; + +public class BlocklistFileTests +{ + private static string WriteTemp(string content) + { + var p = Path.Combine(Path.GetTempPath(), "bl-" + Guid.NewGuid().ToString("N") + ".txt"); + File.WriteAllText(p, content); + return p; + } + + [Fact] + public void Reads_header_generated_and_count() + { + var p = WriteTemp("# modernuo-blocklist v1 generated=2026-07-21T09:24:23Z count=2\n1.2.3.4\n5.6.7.0/24\n"); + Assert.True(BlocklistFile.TryReadHeader(p, out var h)); + Assert.True(h.Present); + Assert.Equal("2026-07-21T09:24:23Z", h.Generated); + Assert.Equal(2, h.Count); + File.Delete(p); + } + + [Fact] + public void Missing_file_reports_absent_and_loads_empty() + { + var p = Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N")); + Assert.False(BlocklistFile.TryReadHeader(p, out var h)); + Assert.False(h.Present); + var snap = BlocklistFile.Load(p, out _, out _); + Assert.False(snap.IsBanned(IPAddress.Parse("1.2.3.4"))); + } + + // Pins the exact line tools/Export-IpBlocklist.ps1 emits: the producer adds informational tokens the + // reader doesn't know about, and the reload detector breaks silently if generated= stops being read. + [Fact] + public void Reads_generator_header_with_extra_tokens() + { + var p = WriteTemp( + "# modernuo-blocklist generated=2026-07-25T18:03:11Z count=12442 ipv4=7868 cidr=4574 feeds=2\n" + + "2.181.183.77\n223.169.0.0/16\n" + ); + + Assert.True(BlocklistFile.TryReadHeader(p, out var h)); + Assert.Equal("2026-07-25T18:03:11Z", h.Generated); + Assert.Equal(12442, h.Count); + + var snap = BlocklistFile.Load(p, out var parsed, out var skipped); + Assert.Equal(2, parsed); + Assert.Equal(0, skipped); + Assert.True(snap.IsBanned(IPAddress.Parse("2.181.183.77"))); + Assert.True(snap.IsBanned(IPAddress.Parse("223.169.4.9"))); + File.Delete(p); + } + + [Fact] + public void Load_parses_body() + { + var p = WriteTemp("# generated=x count=1\n8.8.8.0/24\n"); + var snap = BlocklistFile.Load(p, out var parsed, out _); + Assert.Equal(1, parsed); + Assert.True(snap.IsBanned(IPAddress.Parse("8.8.8.8"))); + File.Delete(p); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs new file mode 100644 index 000000000..4f3ee5ad1 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistFilterTests.cs @@ -0,0 +1,94 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistFilterTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using System.Text; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans.Blocklist; + +// No [Collection] and no static reset hook: the filter is an instance, so each test owns its own +// snapshot and promote-guard. That is the point of it no longer being a static class. +public class BlocklistFilterTests +{ + private static BlocklistFilter WithList(string list, bool reportHits = true, long suppressionMs = 5000) + { + var filter = new BlocklistFilter(); + filter.LoadForTesting( + BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(list), out _, out _), + reportHits, + suppressionMs + ); + + return filter; + } + + [Fact] + public void Listed_address_is_denied_and_reported_once_per_window() + { + var filter = WithList("1.2.3.4"); + var ip = IPAddress.Parse("1.2.3.4"); + + var deny1 = filter.Evaluate(ip, 1000, out var report1); + var deny2 = filter.Evaluate(ip, 1500, out var report2); + var deny3 = filter.Evaluate(ip, 1000 + 5001, out var report3); + + Assert.True(deny1); + Assert.True(report1); + + Assert.True(deny2); + Assert.False(report2); // denied again, but promotion suppressed inside the window + + Assert.True(deny3); + Assert.True(report3); // window elapsed, promotion may be retried + } + + [Fact] + public void Unlisted_address_passes() + { + var filter = WithList("1.2.3.4"); + + Assert.False(filter.Evaluate(IPAddress.Parse("9.9.9.9"), 1, out var report)); + Assert.False(report); + } + + [Fact] + public void Cidr_membership_is_honored() + { + var filter = WithList("10.20.30.0/24"); + + Assert.True(filter.Evaluate(IPAddress.Parse("10.20.30.255"), 1, out _)); + Assert.False(filter.Evaluate(IPAddress.Parse("10.20.31.0"), 1, out _)); + } + + [Fact] + public void ReportHits_disabled_still_denies_but_never_promotes() + { + var filter = WithList("1.2.3.4", reportHits: false); + + Assert.True(filter.Evaluate(IPAddress.Parse("1.2.3.4"), 1, out var report)); + Assert.False(report); + } + + [Fact] + public void Unconfigured_filter_denies_nothing() + { + var filter = new BlocklistFilter(); + + Assert.Equal(0, filter.Count); + Assert.False(filter.ShouldDeny(IPAddress.Parse("8.8.8.8"))); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs new file mode 100644 index 000000000..abdac1d11 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/BlocklistSnapshotTests.cs @@ -0,0 +1,98 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistSnapshotTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans.Blocklist; + +public class BlocklistSnapshotTests +{ + private static BlocklistSnapshot Build(params string[] lines) => + BlocklistSnapshot.Build(System.Text.Encoding.ASCII.GetBytes(string.Join('\n', lines)), out _, out _); + + [Fact] + public void Single_ip_is_matched() + { + var s = Build("1.2.3.4"); + Assert.True(s.IsBanned(IPAddress.Parse("1.2.3.4"))); + Assert.False(s.IsBanned(IPAddress.Parse("1.2.3.5"))); + } + + [Fact] + public void Cidr_contains_and_excludes_boundaries() + { + var s = Build("10.0.0.0/24"); + Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.0"))); + Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.255"))); + Assert.False(s.IsBanned(IPAddress.Parse("10.0.1.0"))); + Assert.False(s.IsBanned(IPAddress.Parse("9.255.255.255"))); + } + + [Fact] + public void Comments_blanks_and_garbage_are_skipped_not_thrown() + { + var s = BlocklistSnapshot.Build( + System.Text.Encoding.ASCII.GetBytes( + string.Join('\n', "# header generated=x", "", "not-an-ip", "1.2.3.4", "::1", "5.6.7.0/24")), + out var parsed, out var skipped); + Assert.Equal(3, parsed); // 1.2.3.4 + ::1 (valid loopback) + 5.6.7.0/24 + Assert.True(skipped >= 1); // "not-an-ip"; blank/comment lines are silently skipped, not counted + Assert.True(s.IsBanned(IPAddress.Parse("5.6.7.200"))); + } + + [Fact] + public void Empty_snapshot_matches_nothing() + { + Assert.False(BlocklistSnapshot.Empty.IsBanned(IPAddress.Parse("1.2.3.4"))); + } + + [Fact] + public void Ipv6_single_and_cidr_are_matched() + { + var s = Build("2001:db8::1", "2001:db8:1::/48"); + Assert.True(s.IsBanned(IPAddress.Parse("2001:db8::1"))); + Assert.True(s.IsBanned(IPAddress.Parse("2001:db8:1::abcd"))); + Assert.False(s.IsBanned(IPAddress.Parse("2001:db8:2::1"))); + } + + [Fact] + public void Ipv4_mapped_ipv6_is_normalized_to_v4() + { + var s = Build("1.2.3.4"); + Assert.True(s.IsBanned(IPAddress.Parse("::ffff:1.2.3.4"))); // must not bypass the v4 set + } + + [Fact] + public void Nested_cidr_intervals_are_coalesced() + { + // A /32 nested inside a /24: InRange's binary search only inspects the + // rightmost interval starting <= ip, so without coalescing an IP inside + // the /24 but outside the /32 would land on the /32 and wrongly pass. + var s = Build("10.0.0.0/24", "10.0.0.5/32"); + Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.100"))); // inside /24, outside /32 + Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.5"))); // the nested /32 itself + Assert.False(s.IsBanned(IPAddress.Parse("10.0.1.0"))); // genuinely outside both + } + + [Fact] + public void Overlapping_cidr_intervals_are_coalesced() + { + var s = Build("10.0.0.0/25", "10.0.0.64/25"); + Assert.True(s.IsBanned(IPAddress.Parse("10.0.0.100"))); // covered by the second /25 + Assert.False(s.IsBanned(IPAddress.Parse("10.0.0.200"))); // outside both + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs new file mode 100644 index 000000000..7c19b8d38 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/Blocklist/PromotedGuardTests.cs @@ -0,0 +1,46 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: PromotedGuardTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.Bans.Blocklist; + +public class PromotedGuardTests +{ + [Fact] + public void First_mark_true_then_suppressed_until_ttl() + { + var g = new PromotedGuard(); + Assert.True(g.TryMark((UInt128)42, 1000, 5000)); + Assert.False(g.TryMark((UInt128)42, 2000, 5000)); // within TTL + Assert.True(g.TryMark((UInt128)42, 6001, 5000)); // expired → re-mark + } + + [Fact] + public void Sweep_removes_expired_entries_allowing_remark() + { + var g = new PromotedGuard(); + Assert.True(g.TryMark((UInt128)7, 0, 1000)); + Assert.False(g.TryMark((UInt128)7, 500, 1000)); // still within TTL + + g.Sweep(500); // not yet expired, sweep should not remove it + Assert.False(g.TryMark((UInt128)7, 999, 1000)); + + g.Sweep(1001); // now expired, sweep removes it + Assert.True(g.TryMark((UInt128)7, 1002, 1000)); // fresh mark, not "still marked" + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs new file mode 100644 index 000000000..756e19b1d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecAlertClientTests.cs @@ -0,0 +1,62 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecAlertClientTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Net; +using Server.Network.Bans.CrowdSec; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class CrowdSecAlertClientTests +{ + /// + /// LAPI's default watcher profile matches the crowdsec/ prefix and answers 401 without it, so + /// this is a protocol constraint rather than a cosmetic product string. + /// + [Fact] + public void UserAgent_IsPrefixedForTheWatcherProfile() + { + Assert.StartsWith("crowdsec/", CrowdSecAlertClient.UserAgent, StringComparison.Ordinal); + } + + [Fact] + public void BuildDeleteQuery_EscapesOrigin() + { + // origin is operator-controlled config (crowdsec.json), so it must be treated as untrusted + // input going into the URL, same as any other interpolated value. + var query = CrowdSecAlertClient.BuildDeleteQuery("modern uo/test&x=1", IPAddress.Parse("1.2.3.4")); + + Assert.Equal("/v1/decisions?origin=modern%20uo%2Ftest%26x%3D1&ip=1.2.3.4", query); + } + + [Fact] + public void BuildDeleteQuery_PlainOrigin_Ipv4() + { + var query = CrowdSecAlertClient.BuildDeleteQuery("modernuo", IPAddress.Parse("192.168.1.1")); + + Assert.Equal("/v1/decisions?origin=modernuo&ip=192.168.1.1", query); + } + + [Fact] + public void BuildDeleteQuery_Ipv6_IsEscaped() + { + // IPv6 textual form contains ':', which Uri.EscapeDataString percent-encodes like any other + // reserved character — confirms the escaping is applied uniformly, not just for IPv4. + var query = CrowdSecAlertClient.BuildDeleteQuery("modernuo", IPAddress.Parse("2001:db8::1")); + + Assert.Equal("/v1/decisions?origin=modernuo&ip=2001%3Adb8%3A%3A1", query); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs new file mode 100644 index 000000000..dd331ac6d --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecConfigurationTests.cs @@ -0,0 +1,82 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecConfigurationTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Text.Json; +using Server.Json; +using Server.Network.Bans.CrowdSec; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class CrowdSecConfigurationTests +{ + // Locks the JsonConfig casing/converter contract: JsonConfig's options are case-SENSITIVE, so + // every settings member must carry an explicit [JsonPropertyName("camelCase")] or it silently + // binds nothing. These tests round-trip through the exact options the loader uses. + + [Fact] + public void CrowdSecSettings_RoundTripsThroughJsonConfig() + { + var original = new CrowdSecSettings + { + LapiUrl = "http://10.0.0.5:9090", + MachineId = "shard", + Password = "secret", + Origin = "modernuo", + ManualBanDuration = TimeSpan.FromHours(168), + FlushInterval = TimeSpan.FromSeconds(1), + MaxQueue = 10000 + }; + + var json = JsonConfig.Serialize(original); + + // camelCase property names must be present (not PascalCase) or the case-sensitive reader binds nothing. + Assert.Contains("\"lapiUrl\"", json); + Assert.Contains("\"machineId\"", json); + Assert.Contains("\"password\"", json); + Assert.Contains("\"origin\"", json); + Assert.Contains("\"manualBanDuration\"", json); + Assert.Contains("\"flushInterval\"", json); + Assert.Contains("\"maxQueue\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.LapiUrl, restored.LapiUrl); + Assert.Equal(original.MachineId, restored.MachineId); + Assert.Equal(original.Password, restored.Password); + Assert.Equal(original.Origin, restored.Origin); + Assert.Equal(original.ManualBanDuration, restored.ManualBanDuration); // TimeSpan survives + Assert.Equal(original.FlushInterval, restored.FlushInterval); // TimeSpan survives + Assert.Equal(original.MaxQueue, restored.MaxQueue); + Assert.True(restored.ReportingEnabled); + } + + [Fact] + public void CrowdSecSettings_Defaults_AreReportingDisabledLocalLoopback() + { + var settings = new CrowdSecSettings(); + + Assert.Equal("http://127.0.0.1:8080", settings.LapiUrl); + Assert.Equal("", settings.MachineId); + Assert.Equal("", settings.Password); + Assert.Equal("modernuo", settings.Origin); + Assert.Equal(TimeSpan.FromHours(168), settings.ManualBanDuration); + Assert.Equal(TimeSpan.FromSeconds(1), settings.FlushInterval); + Assert.Equal(10000, settings.MaxQueue); + Assert.False(settings.ReportingEnabled); // empty machineId/password => inert + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs new file mode 100644 index 000000000..6ad850f34 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Bans/CrowdSecReporterTests.cs @@ -0,0 +1,285 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecReporterTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Server.Network.Bans.CrowdSec; +using Xunit; + +namespace Server.Tests.Network.Bans; + +public class CrowdSecReporterTests +{ + private static CrowdSecSettings Settings() => new() + { + MachineId = "shard", + Password = "secret", + Origin = "modernuo", + ManualBanDuration = TimeSpan.FromHours(168) + }; + + [Fact] + public void BuildAlerts_DedupsByIp() + { + var now = DateTime.UnixEpoch; + var items = new List + { + new(IPAddress.Parse("1.1.1.1"), TimeSpan.FromHours(1), "rate-limit", false), + new(IPAddress.Parse("1.1.1.1"), TimeSpan.FromHours(1), "rate-limit", false), + new(IPAddress.Parse("2.2.2.2"), TimeSpan.FromHours(1), "rate-limit", false) + }; + + var alerts = CrowdSecReporter.BuildAlerts(items, Settings(), now); + + Assert.Equal(2, alerts.Count); + Assert.All(alerts, a => Assert.Single(a.Decisions)); + Assert.Contains(alerts, a => a.Source.Value == "1.1.1.1"); + Assert.Contains(alerts, a => a.Source.Value == "2.2.2.2"); + } + + [Fact] + public void BuildAlerts_ScenarioFromReason_OriginFromSettings() + { + var alerts = CrowdSecReporter.BuildAlerts( + [new(IPAddress.Parse("3.3.3.3"), TimeSpan.FromHours(1), "manual", false)], + Settings(), + DateTime.UnixEpoch); + + var decision = Assert.Single(alerts).Decisions[0]; + Assert.Equal("modernuo/manual", alerts[0].Scenario); + Assert.Equal("modernuo", decision.Origin); + Assert.Equal("ban", decision.Type); + Assert.Equal("Ip", decision.Scope); + Assert.Equal("3.3.3.3", decision.Value); + } + + [Fact] + public void BuildAlerts_ScenarioFromReason_Blocklist() + { + var alerts = CrowdSecReporter.BuildAlerts( + [new(IPAddress.Parse("5.5.5.5"), TimeSpan.FromHours(1), "blocklist", false)], + Settings(), + DateTime.UnixEpoch); + + var decision = Assert.Single(alerts).Decisions[0]; + Assert.Equal("modernuo/blocklist", alerts[0].Scenario); + Assert.Equal("modernuo/blocklist", decision.Scenario); + } + + /// + /// LAPI dereferences scenario_hash/scenario_version unconditionally when persisting an alert, so an + /// omitted field is a 500, not a validation error. Asserted on the serialized payload rather than the + /// DTO because that is what actually goes on the wire. + /// + [Fact] + public void BuildAlerts_SerializedPayload_CarriesRequiredScenarioFields() + { + var alerts = CrowdSecReporter.BuildAlerts( + [new(IPAddress.Parse("9.9.9.9"), TimeSpan.FromHours(1), "rate-limit", false)], + Settings(), + DateTime.UnixEpoch); + + var payload = JsonSerializer.SerializeToNode(alerts)!.AsArray()[0]!.AsObject(); + + Assert.True(payload.ContainsKey("scenario_hash")); + Assert.True(payload.ContainsKey("scenario_version")); + Assert.Equal(JsonValueKind.String, payload["scenario_hash"]!.GetValue().ValueKind); + Assert.Equal(JsonValueKind.String, payload["scenario_version"]!.GetValue().ValueKind); + Assert.Equal(1, payload["capacity"]!.GetValue()); + } + + /// + /// ':' is the time-separator specifier in a custom .NET format string, so a shard under fi-FI used to + /// emit "T00.00.00.000Z" — which Go's time.RFC3339 rejects, and LAPI answers 500 for. th-TH additionally + /// shifts the year via the Buddhist calendar. + /// + [Theory] + [InlineData("fi-FI")] + [InlineData("th-TH")] + [InlineData("ar-SA")] + public void FormatTimestamp_IsIso8601_RegardlessOfCulture(string culture) + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(culture); + Assert.Equal("1970-01-01T00:00:00.000Z", CrowdSecReporter.FormatTimestamp(DateTime.UnixEpoch)); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + /// A non-UTC input must still be stamped as UTC — the trailing 'Z' is a literal, not a claim. + [Fact] + public void FormatTimestamp_ConvertsNonUtcInput() + { + var local = new DateTimeOffset(1970, 1, 1, 2, 0, 0, TimeSpan.FromHours(2)).LocalDateTime; + + Assert.Equal("1970-01-01T00:00:00.000Z", CrowdSecReporter.FormatTimestamp(local)); + } + + [Fact] + public void FormatDuration_UsesSeconds_FloorsAtOne() + { + Assert.Equal("3600s", CrowdSecReporter.FormatDuration(TimeSpan.FromHours(1))); + Assert.Equal("1s", CrowdSecReporter.FormatDuration(TimeSpan.Zero)); + Assert.Equal("1s", CrowdSecReporter.FormatDuration(TimeSpan.FromMilliseconds(10))); + } + + [Fact] + public void Report_WhenQueueFull_DropsAndCounts() + { + var reporter = new CrowdSecReporter(new NullAlertClient(), new CrowdSecSettings + { + MachineId = "shard", + Password = "secret", + MaxQueue = 2 + }); + // Do NOT Start() the drain — so the queue fills and overflows deterministically. + + for (var i = 0; i < 10; i++) + { + reporter.Report(IPAddress.Parse("4.4.4." + i), TimeSpan.FromHours(1), "rate-limit"); + } + + Assert.True(reporter.DroppedCount >= 8); + } + + // Stop() without a prior Start() drives FlushRemainingOnStop() synchronously (no drain task, no + // Task.Delay backoff involved), so this is deterministic — no wall-clock timing dependency. + [Fact] + public void Stop_FlushesQueuedReports_ViaClient() + { + var client = new RecordingAlertClient(); + var reporter = new CrowdSecReporter(client, Settings()); + + reporter.Report(IPAddress.Parse("6.6.6.6"), TimeSpan.FromHours(1), "rate-limit"); + reporter.Stop(); + + var posted = Assert.Single(client.Posted); + Assert.Equal("6.6.6.6", Assert.Single(posted).Source.Value); + Assert.Equal(0, reporter.SendFailureCount); + } + + [Fact] + public void Stop_FlushesQueuedRetracts_ViaClient() + { + var client = new RecordingAlertClient(); + var reporter = new CrowdSecReporter(client, Settings()); + + reporter.Retract(IPAddress.Parse("8.8.8.8")); + reporter.Stop(); + + Assert.Equal(IPAddress.Parse("8.8.8.8"), Assert.Single(client.Deleted)); + Assert.Equal(0, reporter.SendFailureCount); + } + + [Fact] + public void Stop_WhenFlushSendFails_CountsSendFailure() + { + var reporter = new CrowdSecReporter(new ThrowingAlertClient(), Settings()); + + reporter.Report(IPAddress.Parse("7.7.7.7"), TimeSpan.FromHours(1), "rate-limit"); + reporter.Stop(); + + Assert.Equal(1, reporter.SendFailureCount); + } + + [Fact] + public void Stop_WithEmptyQueue_DoesNotInvokeClientOrFail() + { + var client = new RecordingAlertClient(); + var reporter = new CrowdSecReporter(client, Settings()); + + reporter.Stop(); + + Assert.Empty(client.Posted); + Assert.Equal(0, reporter.SendFailureCount); + } + + /// + /// The drain task must track the loop's lifetime, not just its first await — a ValueTask-returning + /// drain loop passed to Task.Run yields a Task<ValueTask> that completes immediately, which makes + /// Stop()'s drain-exited handshake a no-op. With an empty queue the loop parks on WaitToReadAsync, + /// so a correctly unwrapped task cannot win this race; a slow pool only under-detects. + /// + [Fact] + public async Task Start_DrainTaskSpansLoopLifetime_NotJustTheFirstAwait() + { + var reporter = new CrowdSecReporter(new NullAlertClient(), Settings()); + using var cts = new CancellationTokenSource(); + + reporter.Start(cts.Token); + + var drain = reporter.DrainTaskForTesting; + Assert.NotNull(drain); + + var first = await Task.WhenAny(drain, Task.Delay(TimeSpan.FromMilliseconds(500))); + Assert.False(ReferenceEquals(first, drain), "drain task completed while the loop was still running"); + + reporter.Stop(); + + Assert.True(drain.IsCompleted, "Stop() returned before the drain loop exited"); + } + + private sealed class NullAlertClient : ICrowdSecAlertClient + { + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => + ValueTask.CompletedTask; + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => + ValueTask.CompletedTask; + + public void Dispose() { } + } + + private sealed class RecordingAlertClient : ICrowdSecAlertClient + { + public List> Posted { get; } = []; + public List Deleted { get; } = []; + + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) + { + Posted.Add(alerts); + return ValueTask.CompletedTask; + } + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) + { + Deleted.Add(ip); + return ValueTask.CompletedTask; + } + + public void Dispose() { } + } + + private sealed class ThrowingAlertClient : ICrowdSecAlertClient + { + public ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) => + throw new InvalidOperationException("simulated LAPI outage"); + + public ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) => + ValueTask.CompletedTask; + + public void Dispose() { } + } +} diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallEntryTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallEntryTests.cs similarity index 100% rename from Projects/Server.Tests/Tests/Network/Firewall/FirewallEntryTests.cs rename to Projects/UOContent.Tests/Tests/Network/Firewall/FirewallEntryTests.cs diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs new file mode 100644 index 000000000..01691e223 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs @@ -0,0 +1,72 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallPersistenceTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Net; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network.Firewall; + +[Collection("Sequential UOContent Tests")] +public class FirewallPersistenceTests +{ + [Fact] + public void ToSettings_RoundTrips_PermanentAndTtl() + { + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("1.2.3.4"))); + Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("2.2.2.2")), TimeSpan.FromHours(1)); + + var settings = Server.Network.Firewall.ToSettings(); + + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.LoadFrom(settings); + + Assert.True(Server.Network.Firewall.IsBlocked(IPAddress.Parse("1.2.3.4"))); + Assert.True(Server.Network.Firewall.IsBlocked(IPAddress.Parse("2.2.2.2"))); + } + + [Fact] + public void LoadFrom_SkipsAlreadyExpired() + { + Server.Network.Firewall.ResetForTesting(); + var settings = new FirewallSettings + { + Entries = + [ + // Core.Now, matching the clock LoadFrom compares against. + new FirewallEntryRecord { Value = "9.9.9.9", Expires = Core.Now.AddHours(-1) } + ] + }; + + Server.Network.Firewall.LoadFrom(settings); + + Assert.False(Server.Network.Firewall.IsBlocked(IPAddress.Parse("9.9.9.9"))); + } + + [Fact] + public void ToSettings_OmitsExpiryForPermanent() + { + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.Add(new SingleIpFirewallEntry(IPAddress.Parse("1.2.3.4"))); + + var settings = Server.Network.Firewall.ToSettings(); + + Assert.Single(settings.Entries); + Assert.Null(settings.Entries[0].Expires); + Assert.Equal("1.2.3.4", settings.Entries[0].Value); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs new file mode 100644 index 000000000..fabf6d4ba --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs @@ -0,0 +1,89 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Net; +using Server.Network; +using Xunit; + +namespace Server.Tests.Network.Firewall; + +[Collection("Sequential UOContent Tests")] +public class FirewallTests +{ + private static IPAddress Ip(string s) => IPAddress.Parse(s); + + [Fact] + public void Add_ThenIsBlocked_SingleIp() + { + Server.Network.Firewall.ResetForTesting(); + Assert.True(Server.Network.Firewall.Add(new SingleIpFirewallEntry(Ip("1.2.3.4")))); + Assert.True(Server.Network.Firewall.IsBlocked(Ip("1.2.3.4"))); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("1.2.3.5"))); + } + + [Fact] + public void Add_Range_BlocksInside_NotOutside() + { + Server.Network.Firewall.ResetForTesting(); + Server.Network.Firewall.Add(new CidrFirewallEntry(Ip("10.0.0.0"), Ip("10.0.0.255"))); + Assert.True(Server.Network.Firewall.IsBlocked(Ip("10.0.0.7"))); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("10.0.1.0"))); + } + + [Fact] + public void Remove_Unblocks() + { + Server.Network.Firewall.ResetForTesting(); + var entry = new SingleIpFirewallEntry(Ip("1.2.3.4")); + Server.Network.Firewall.Add(entry); + Assert.True(Server.Network.Firewall.Remove(entry)); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("1.2.3.4"))); + } + + [Fact] + public void ExpireEntries_RemovesExpired_KeepsPermanent() + { + Server.Network.Firewall.ResetForTesting(); + var permanent = new SingleIpFirewallEntry(Ip("1.1.1.1")); + var temporary = new SingleIpFirewallEntry(Ip("2.2.2.2")); + Server.Network.Firewall.Add(permanent); // no ttl + Server.Network.Firewall.Add(temporary, TimeSpan.FromMilliseconds(50)); // ttl + + Server.Network.Firewall.ExpireEntries(Core.TickCount + 100); // past the ttl + + Assert.True(Server.Network.Firewall.IsBlocked(Ip("1.1.1.1"))); + Assert.False(Server.Network.Firewall.IsBlocked(Ip("2.2.2.2"))); + } + + [Fact] + public void ToFirewallEntry_ParsesForms() + { + Assert.IsType(Server.Network.Firewall.ToFirewallEntry("1.2.3.4")); + Assert.IsType(Server.Network.Firewall.ToFirewallEntry("10.0.0.0/24")); + Assert.IsType(Server.Network.Firewall.ToFirewallEntry("10.0.0.0-10.0.0.255")); + Assert.Null(Server.Network.Firewall.ToFirewallEntry("not-an-ip")); + } + + [Fact] + public void ReadFirewallSet_SurfacesAddedEntries() + { + Server.Network.Firewall.ResetForTesting(); + var entry = new SingleIpFirewallEntry(Ip("1.2.3.4")); + Server.Network.Firewall.Add(entry); + + Server.Network.Firewall.ReadFirewallSet(set => Assert.Contains(entry, set)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs b/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs new file mode 100644 index 000000000..7f3780e58 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/LoginAllowlist/LoginAllowlistTests.cs @@ -0,0 +1,218 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: LoginAllowlistTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using Server.Network; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.LoginAllowlists; + +// The list is static, so every test resets it first. Addresses come from TEST-NET-3 (203.0.113.0/24) so they +// cannot collide with the blocklist tests if xUnit runs the two classes at the same time. +public class LoginAllowlistTests +{ + private const long Ttl = 90 * 24 * 60 * 60; // 90 days, the shipped default + private const long Window = 3600; + private const long Now = 1_800_000_000; + + private static void Reset(bool enabled = true, int strikes = 0) => + LoginAllowlist.LoadForTesting(enabled, Ttl, strikes, Window); + + [Fact] + public void Recent_login_allows_its_address() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.10"); + + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsAllowed(ip, Now)); + Assert.True(LoginAllowlist.IsAllowed(ip, Now + Ttl / 2)); + } + + [Fact] + public void Entry_lapses_once_past_the_ttl() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.11"); + + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsAllowed(ip, Now + Ttl)); // the boundary still counts + Assert.False(LoginAllowlist.IsAllowed(ip, Now + Ttl + 1)); // a second later it does not + } + + [Fact] + public void Logging_in_again_renews_the_window() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.12"); + + LoginAllowlist.RecordLogin(ip, Now); + LoginAllowlist.RecordLogin(ip, Now + Ttl); // still allowed here, so the entry is refreshed + + Assert.True(LoginAllowlist.IsAllowed(ip, Now + Ttl + Ttl)); + } + + [Fact] + public void Unknown_address_is_not_allowed() + { + Reset(); + LoginAllowlist.RecordLogin(IPAddress.Parse("203.0.113.13"), Now); + + Assert.False(LoginAllowlist.IsAllowed(IPAddress.Parse("203.0.113.14"), Now)); + } + + [Fact] + public void Private_addresses_are_never_recorded() + { + Reset(); + + // A LAN or loopback login says nothing about the public internet. + LoginAllowlist.RecordLogin(IPAddress.Parse("10.0.0.5"), Now); + LoginAllowlist.RecordLogin(IPAddress.Loopback, Now); + + Assert.False(LoginAllowlist.IsAllowed(IPAddress.Parse("10.0.0.5"), Now)); + Assert.False(LoginAllowlist.IsAllowed(IPAddress.Loopback, Now)); + Assert.Equal(0, LoginAllowlist.Count); + } + + [Fact] + public void Disabled_list_allows_nobody() + { + Reset(enabled: false); + var ip = IPAddress.Parse("203.0.113.15"); + + LoginAllowlist.RecordLogin(ip, Now); + + Assert.False(LoginAllowlist.IsAllowed(ip, Now)); + Assert.Equal(0, LoginAllowlist.Count); + } + + [Fact] + public void Null_address_is_handled() + { + Reset(); + + LoginAllowlist.RecordLogin(null, Now); + + Assert.False(LoginAllowlist.IsAllowed(null, Now)); + } + + // ----- escalation ------------------------------------------------------------------------------- + + [Fact] + public void Manual_bans_are_never_exempt() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.20"); + LoginAllowlist.RecordLogin(ip, Now); + + // An explicit decision must reach the reporters even for an allowlisted address. + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.Manual, Now)); + } + + [Fact] + public void Unopted_reasons_are_never_exempt() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.21"); + LoginAllowlist.RecordLogin(ip, Now); + + // A reason nobody opted into IsBehavioral escalates normally rather than inheriting an exemption. + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, "some-future-reason", Now)); + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.Blocklist, Now)); + } + + [Fact] + public void Behavioral_reasons_are_exempt_while_allowed() + { + Reset(); + var ip = IPAddress.Parse("203.0.113.22"); + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.SilentConnect, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.InvalidSeed, Now)); + } + + [Fact] + public void Entry_is_revoked_once_the_strikes_run_out() + { + Reset(strikes: 3); + var ip = IPAddress.Parse("203.0.113.23"); + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + + // The third strike is the one that escalates, and it takes the entry with it. + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.False(LoginAllowlist.IsAllowed(ip, Now)); + + // Still revoked afterwards, so everything from here escalates too. + Assert.False(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + } + + [Fact] + public void Quiet_window_clears_the_tally() + { + Reset(strikes: 3); + var ip = IPAddress.Parse("203.0.113.24"); + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + + // Past the window the count restarts, so an occasional tripper never accumulates to revocation. + var later = Now + Window + 1; + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, later)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, later)); + Assert.True(LoginAllowlist.IsAllowed(ip, later)); + } + + [Fact] + public void Logging_in_again_forgives_accumulated_strikes() + { + Reset(strikes: 3); + var ip = IPAddress.Parse("203.0.113.25"); + LoginAllowlist.RecordLogin(ip, Now); + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + + LoginAllowlist.RecordLogin(ip, Now); // someone proved they hold an account again + + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + Assert.True(LoginAllowlist.IsAllowed(ip, Now)); + } + + [Fact] + public void Zero_threshold_disables_revocation() + { + Reset(strikes: 0); + var ip = IPAddress.Parse("203.0.113.26"); + LoginAllowlist.RecordLogin(ip, Now); + + for (var i = 0; i < 50; i++) + { + Assert.True(LoginAllowlist.IsExemptFromEscalation(ip, BanReasons.RateLimit, Now)); + } + + Assert.True(LoginAllowlist.IsAllowed(ip, Now)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs b/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs new file mode 100644 index 000000000..1146414b5 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/ManualAllowlist/ManualAllowlistConfigurationTests.cs @@ -0,0 +1,66 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ManualAllowlistConfigurationTests.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Text.Json; +using Server.Json; +using Server.Network.Bans; +using Xunit; + +namespace Server.Tests.Network.ManualAllowlists; + +public class ManualAllowlistConfigurationTests +{ + // Locks the JsonConfig casing contract: JsonConfig's options are case-SENSITIVE, so every settings + // member must carry an explicit [JsonPropertyName("camelCase")] or it silently binds nothing. + [Fact] + public void ManualAllowlistSettings_RoundTripsThroughJsonConfig() + { + var original = new ManualAllowlistSettings + { + Enabled = true, + Files = ["D:/shared/ip-allowlist*.txt"], + ReloadInterval = TimeSpan.FromMinutes(5) + }; + + var json = JsonConfig.Serialize(original); + + Assert.Contains("\"enabled\"", json); + Assert.Contains("\"files\"", json); + Assert.Contains("\"reloadInterval\"", json); + + var restored = JsonSerializer.Deserialize(json, JsonConfig.DefaultOptions); + + Assert.NotNull(restored); + Assert.Equal(original.Enabled, restored.Enabled); + Assert.Equal(original.Files, restored.Files); + Assert.Equal(original.ReloadInterval, restored.ReloadInterval); + } + + // The point of the flag: a shard that never opts in must not start the reload poll. + [Fact] + public void Manual_allowlist_is_off_by_default() + { + Assert.False(new ManualAllowlistSettings().Enabled); + } + + // The generator creates ip-allowlist.txt beside the blocklist; the wildcard is what picks up a + // carve-out file (-RefreshCarveouts writes ip-allowlist-starlink.txt) with no config edit. + [Fact] + public void Default_pattern_matches_the_generator_output_path() + { + Assert.Equal(["Configuration/ip-allowlist*.txt"], new ManualAllowlistSettings().Files); + } +} diff --git a/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs new file mode 100644 index 000000000..456eb174a --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Network/Packets/AuthIdTests.cs @@ -0,0 +1,382 @@ +using System; +using System.Net; +using Server.Accounting; +using Server.Accounting.Security; +using Server.Network; +using Server.Tests.Network; +using Xunit; + +namespace Server.Tests.Network.Packets; + +[Collection("Sequential UOContent Tests")] +public class AuthIdTests : IDisposable +{ + private static readonly IPAddress AddressX = IPAddress.Parse("203.0.113.10"); + private static readonly IPAddress AddressY = IPAddress.Parse("203.0.113.11"); + + private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm; + + public AuthIdTests() + { + AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2; + IncomingAccountPackets.ClearAuthIdWindow(); + } + + public void Dispose() + { + IncomingAccountPackets.ClearAuthIdWindow(); + AccountSecurity.CurrentAlgorithm = _originalAlgorithm; + } + + private static IAccount CreateAccount(string username) => + Accounts.GetAccount(username) ?? new Account(username, "hunter2"); + + private static int Register(IAccount account, IPAddress address) => + IncomingAccountPackets.RegisterAuthId(account, address, new ClientVersion(7, 0, 0, 0)); + + [Fact] + public void VouchesForTheAccountAndAddressItWasIssuedTo() + { + var account = CreateAccount("authid-match-user"); + var authId = Register(account, AddressX); + + var result = IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry); + + Assert.Equal(IncomingAccountPackets.AuthIdResult.Vouched, result); + Assert.Same(account, entry.Account); + } + + [Fact] + public void RejectsADifferentAccount() + { + var issued = CreateAccount("authid-owner-user"); + var other = CreateAccount("authid-other-user"); + var authId = Register(issued, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, other.Username, AddressX, out _) + ); + } + + [Fact] + public void RejectsADifferentAddress() + { + var account = CreateAccount("authid-switch-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _) + ); + } + + [Fact] + public void MatchesTheUsernameCaseInsensitively() + { + var account = CreateAccount("AuthId-Case-User"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, "authid-case-user", AddressX, out _) + ); + } + + [Fact] + public void MatchesAnIPv4MappedIPv6Address() + { + var account = CreateAccount("authid-mapped-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX.MapToIPv6(), out _) + ); + } + + [Fact] + public void RejectsAnUnknownAuthId() + { + var account = CreateAccount("authid-unknown-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId + 1, account.Username, AddressX, out _) + ); + } + + [Fact] + public void IsSingleUseAfterASuccess() + { + var account = CreateAccount("authid-once-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + } + + // A rejected attempt must not consume the id, or anyone landing on a live one could burn it and + // force its owner to log in again. + [Fact] + public void SurvivesAnAttemptFromTheWrongAddress() + { + var account = CreateAccount("authid-not-burned-address-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _) + ); + + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + } + + [Fact] + public void SurvivesAnAttemptForTheWrongAccount() + { + var account = CreateAccount("authid-not-burned-account-user"); + var authId = Register(account, AddressX); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out _) + ); + + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + } + + [Fact] + public void ARejectedAttemptYieldsNoEntry() + { + var account = CreateAccount("authid-no-leak-user"); + var authId = Register(account, AddressX); + + IncomingAccountPackets.ConsumeAuthId(authId, "not-the-owner", AddressX, out var entry); + + Assert.Null(entry.Account); + } + + [Fact] + public void AnExpiredIdIsSpentByItsOwner() + { + var account = CreateAccount("authid-expired-spent-user"); + var authId = Register(account, AddressX); + + var now = Core._now; + + try + { + Core._now = now + TimeSpan.FromMinutes(30.0); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Expired, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out _) + ); + Assert.Equal(0, IncomingAccountPackets.AuthIdWindowCount); + } + finally + { + Core._now = now; + } + } + + // Expiry is not a lockout. The game login always verified the password before any of this + // existed, so falling back to that verify is the behaviour we started from. + [Fact] + public void ExpiresIntoAPasswordVerifyRatherThanARejection() + { + var account = CreateAccount("authid-expired-user"); + var authId = Register(account, AddressX); + + var now = Core._now; + + try + { + Core._now = now + TimeSpan.FromMinutes(30.0); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Expired, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressX, out var entry) + ); + + // Still carries the client version the game login needs. + Assert.Equal(new ClientVersion(7, 0, 0, 0), entry.Version); + } + finally + { + Core._now = now; + } + } + + [Fact] + public void AnExpiredIdFromAnotherAddressIsStillRejected() + { + var account = CreateAccount("authid-expired-elsewhere-user"); + var authId = Register(account, AddressX); + + var now = Core._now; + + try + { + Core._now = now + TimeSpan.FromMinutes(30.0); + + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Rejected, + IncomingAccountPackets.ConsumeAuthId(authId, account.Username, AddressY, out _) + ); + } + finally + { + Core._now = now; + } + } + + private static int Ensure(int existingAuthId, IAccount account, IPAddress address) => + IncomingAccountPackets.EnsureAuthId( + existingAuthId, + account, + address, + new ClientVersion(7, 0, 0, 0) + ); + + [Fact] + public void IssuesAnIdWhenTheConnectionHasNone() + { + var account = CreateAccount("authid-first-select-user"); + + var authId = Ensure(0, account, AddressX); + + Assert.NotEqual(0, authId); + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + } + + // Handing the same id back rather than minting another is what makes an orphan impossible, + // instead of something to clean up afterwards. + [Fact] + public void ReSelectingReturnsTheSameIdAndAddsNothingToTheWindow() + { + var account = CreateAccount("authid-reselect-user"); + var first = Ensure(0, account, AddressX); + + for (var i = 0; i < 10; i++) + { + Assert.Equal(first, Ensure(first, account, AddressX)); + } + + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(first, account.Username, AddressX, out _) + ); + } + + [Fact] + public void AbandonedIdsAreSweptWhenNewOnesAreIssued() + { + var abandoned = CreateAccount("authid-abandoned-user"); + var live = CreateAccount("authid-live-user"); + + var now = Core._now; + + try + { + for (var i = 0; i < 128; i++) + { + Register(abandoned, AddressX); + } + + Assert.Equal(128, IncomingAccountPackets.AuthIdWindowCount); + + Core._now = now + TimeSpan.FromMinutes(30.0); + + var liveId = Register(live, AddressX); + + Assert.Equal(1, IncomingAccountPackets.AuthIdWindowCount); + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(liveId, live.Username, AddressX, out _) + ); + } + finally + { + Core._now = now; + } + } + + // A login rush is not a backlog. Every id belongs to a client on its way to redeem it, so none + // may be discarded to hold the window at some arbitrary size. + [Fact] + public void ALoginRushDoesNotEvictAnyonesAuthId() + { + var account = CreateAccount("authid-rush-user"); + var ids = new int[800]; + + for (var i = 0; i < ids.Length; i++) + { + ids[i] = Register(account, AddressX); + } + + Assert.Equal(ids.Length, IncomingAccountPackets.AuthIdWindowCount); + + // Every id issued during the rush is still redeemable, including the first one. + for (var i = 0; i < ids.Length; i++) + { + Assert.Equal( + IncomingAccountPackets.AuthIdResult.Vouched, + IncomingAccountPackets.ConsumeAuthId(ids[i], account.Username, AddressX, out _) + ); + } + } + + [Fact] + public void PreAuthenticatedGameLogin_SkipsThePasswordCheck() + { + var account = CreateAccount("authid-preauth-user"); + using var ns = PacketTestUtilities.CreateTestNetState(); + + // A wrong password is accepted only because the auth id already vouched for the account. + var e = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", true); + GameServer.GameServerLoginEvent(e); + + Assert.True(e.Accepted); + } + + [Fact] + public void GameLoginWithoutPreAuthentication_StillChecksThePassword() + { + var account = CreateAccount("authid-nopreauth-user"); + using var ns = PacketTestUtilities.CreateTestNetState(); + + var wrong = new GameServer.GameLoginEventArgs(ns, account.Username, "wrong-password", false); + GameServer.GameServerLoginEvent(wrong); + Assert.False(wrong.Accepted); + + var right = new GameServer.GameLoginEventArgs(ns, account.Username, "hunter2", false); + GameServer.GameServerLoginEvent(right); + Assert.True(right.Accepted); + } + + [Fact] + public void GeneratesDistinctAuthIds() + { + var account = CreateAccount("authid-distinct-user"); + + Assert.NotEqual(Register(account, AddressX), Register(account, AddressX)); + } +} diff --git a/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs b/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs index fa6de64a5..314290264 100644 --- a/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs +++ b/Projects/UOContent.Tests/Tests/Utilities/TryParseTests.cs @@ -1,3 +1,4 @@ +using System; using Xunit; namespace Server.Tests.Utility; @@ -18,4 +19,29 @@ public class TryParseTests Assert.Equal(parsedAs, constructed); } } + + [Theory] + // Parsed directly into the target type (INumber.TryParse), not via ulong + Convert.ChangeType. + [InlineData(typeof(int), "42", true, 42)] + [InlineData(typeof(int), "-5", true, -5)] // signed values parse directly now + [InlineData(typeof(int), "0xFF", true, 255)] // hex + [InlineData(typeof(int), "notanumber", false, null)] + [InlineData(typeof(byte), "255", true, (byte)255)] + [InlineData(typeof(byte), "256", false, null)] // out of the byte range + [InlineData(typeof(uint), "4294967295", true, 4294967295u)] + [InlineData(typeof(long), "-9000000000", true, -9000000000L)] + public void TestTryParseNumeric(Type type, string value, bool success, object expected) + { + var error = Server.Types.TryParse(type, value, out var constructed); + + if (success) + { + Assert.Null(error); + Assert.Equal(expected, constructed); + } + else + { + Assert.NotNull(error); + } + } } diff --git a/Projects/UOContent.Tests/UOContent.Tests.csproj b/Projects/UOContent.Tests/UOContent.Tests.csproj index 712246f72..1c73f6eb4 100644 --- a/Projects/UOContent.Tests/UOContent.Tests.csproj +++ b/Projects/UOContent.Tests/UOContent.Tests.csproj @@ -4,9 +4,9 @@ Debug;Release;Analyze - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -15,7 +15,6 @@ - diff --git a/Projects/UOContent/Accounting/Account.cs b/Projects/UOContent/Accounting/Account.cs index d79d70b1a..b583585ee 100644 --- a/Projects/UOContent/Accounting/Account.cs +++ b/Projects/UOContent/Accounting/Account.cs @@ -378,28 +378,54 @@ public partial class Account : IAccount, IComparable public void SetPassword(string plainPassword) { - var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 - ? $"{_username}{plainPassword}" - : plainPassword; - - Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(phrase); PasswordAlgorithm = AccountSecurity.CurrentAlgorithm; + Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword( + AccountSecurity.DerivePhrase(PasswordAlgorithm, _username, plainPassword) + ); + } + + /// The phrase that verifies against the currently stored hash. + internal string GetVerifyPhrase(string plainPassword) => + AccountSecurity.DerivePhrase(_passwordAlgorithm, _username, plainPassword); + + /// The phrase a rehash to the configured algorithm would be derived from. + internal string GetRehashPhrase(string plainPassword) => + AccountSecurity.DerivePhrase(AccountSecurity.CurrentAlgorithm, _username, plainPassword); + + /// + /// Whether a successful login should rewrite the stored hash, because the algorithm changed or + /// its cost parameters moved. + /// + internal bool NeedsPasswordUpgrade() => + _passwordAlgorithm != AccountSecurity.CurrentAlgorithm || + AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password); + + /// + /// Applies a hash derived off the game loop. Distinct from the private UpgradePassword + /// below, which adopts a legacy hash when loading pre-binary XML accounts. + /// + /// Unguarded: dispatch is on the loop, one worker drains FIFO, and results return through the + /// loop context in that order, so last dispatched is last applied. A second worker would need + /// ordering reintroduced here. + /// + internal void ApplyPasswordWrite(string newEncrypted, PasswordProtectionAlgorithm algorithm) + { + PasswordAlgorithm = algorithm; + Password = newEncrypted; } public bool CheckPassword(string plainPassword) { - var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 - ? $"{_username}{plainPassword}" - : plainPassword; + var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm) + .ValidatePassword(Password, GetVerifyPhrase(plainPassword)); - var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm).ValidatePassword(Password, phrase); if (!ok) { return false; } // Upgrade the password protection in case we change the algorithm - if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm) + if (NeedsPasswordUpgrade()) { SetPassword(plainPassword); } diff --git a/Projects/UOContent/Accounting/AccountHandler.cs b/Projects/UOContent/Accounting/AccountHandler.cs index 1e676ed9e..eea0dc069 100644 --- a/Projects/UOContent/Accounting/AccountHandler.cs +++ b/Projects/UOContent/Accounting/AccountHandler.cs @@ -5,6 +5,7 @@ using System.Net; using System.Runtime.CompilerServices; using ModernUO.CodeGeneratedEvents; using Server.Accounting; +using Server.Accounting.Security; using Server.Engines.CharacterCreation; using Server.Engines.Help; using Server.Logging; @@ -69,6 +70,9 @@ public static class AccountHandler public static void Initialize() { EventSink.AccountLogin += EventSink_AccountLogin; + + EventSink.Shutdown += PasswordWorker.Stop; + EventSink.ServerCrashed += PasswordWorker.OnCrashed; } [Usage("Password ")] @@ -139,8 +143,12 @@ public static class AccountHandler if (accessList[0].MatchClassC(ipAddress)) { - acct.SetPassword(pass); - from.SendMessage("The password to your account has changed."); + // Confirmed from the callback: off-loop the write has not landed yet here. + PasswordWorker.SetPassword( + acct, + pass, + _ => from.SendMessage("The password to your account has changed.") + ); } else { @@ -307,24 +315,129 @@ public static class AccountHandler logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un); e.RejectReason = LockdownLevel > AccessLevel.Player ? ALRReason.BadComm : ALRReason.BadPass; } - else if (!acct.CheckPassword(pw)) + else { - logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un); - e.RejectReason = ALRReason.BadPass; + HandlePasswordCheck(e, acct, pw); } - else if (acct.Banned) + } + + /// + /// Separate from the caller's else-if chain because two outcomes are not verdicts: the off-loop + /// path has none yet, and a full queue must reject rather than fall through and verify. + /// + private static void HandlePasswordCheck(AccountLoginEventArgs e, Account acct, string pw) + { + switch (DispatchPasswordCheck(e, acct, pw)) { - logger.Information("Login: {NetState} Banned account '{Username}'", e.State, un); + case PasswordCheckDispatch.Deferred: + { + e.Deferred = true; + return; + } + case PasswordCheckDispatch.Saturated: + { + // Reject rather than verify inline: steering work back onto the loop is what a + // flood wants. + logger.Warning( + "Login: {NetState} Password verification queue full, rejecting '{Username}'", + e.State, + acct.Username + ); + + e.RejectReason = ALRReason.BadComm; + return; + } + } + + if (!acct.CheckPassword(pw)) + { + logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, acct.Username); + e.RejectReason = ALRReason.BadPass; + return; + } + + ApplyVerifiedLogin(e, acct); + } + + /// Everything after the password is known good, shared so an off-loop verdict lands + /// in the same state as an inline one. + private static void ApplyVerifiedLogin(AccountLoginEventArgs e, Account acct) + { + if (acct.Banned) + { + logger.Information("Login: {NetState} Banned account '{Username}'", e.State, acct.Username); e.RejectReason = ALRReason.Blocked; + return; + } + + logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, acct.Username); + e.State.Account = acct; + e.Accepted = true; + + acct.LogAccess(e.State); + LoginAllowlist.RecordLogin(e.State?.Address); + } + + private enum PasswordCheckDispatch + { + /// Verify on the loop. + Inline, + + /// Handed to the worker; no verdict yet. + Deferred, + + /// The queue is full. + Saturated + } + + /// + /// Hands the password check to the worker, whatever algorithm it uses. Every protection is safe + /// off the loop, so there is no carve-out, and a cheap digest does not need one either: + /// AccountSecurity.Configure refuses anything below SHA2 as the configured algorithm, so + /// MD5 and SHA1 only appear as a stored hash awaiting migration. That makes + /// NeedsPasswordUpgrade true, and the upgrade hash dominates the job. + /// + private static PasswordCheckDispatch DispatchPasswordCheck(AccountLoginEventArgs e, Account acct, string pw) + { + if (!PasswordWorker.Enabled) + { + return PasswordCheckDispatch.Inline; + } + + var job = new PasswordJob + { + Account = acct, + State = e.State, + StoredHash = acct.Password, + StoredAlgorithm = acct.PasswordAlgorithm, + VerifyPhrase = acct.GetVerifyPhrase(pw), + HashPhrase = acct.NeedsPasswordUpgrade() ? acct.GetRehashPhrase(pw) : null, + TargetAlgorithm = AccountSecurity.CurrentAlgorithm, + OnComplete = static (j, outcome) => + CompleteDeferredAccountLogin(j.State, j.Account, outcome.Verified) + }; + + return PasswordWorker.TryEnqueue(job) + ? PasswordCheckDispatch.Deferred + : PasswordCheckDispatch.Saturated; + } + + /// Resumes a login whose password check ran on the verification thread. + internal static void CompleteDeferredAccountLogin(NetState state, Account acct, bool verified) + { + var e = new AccountLoginEventArgs(state, acct.Username, null); + + if (verified) + { + ApplyVerifiedLogin(e, acct); } else { - logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, un); - e.State.Account = acct; - e.Accepted = true; - - acct.LogAccess(e.State); + logger.Information("Login: {NetState} Invalid password for '{Username}'", state, acct.Username); + e.RejectReason = ALRReason.BadPass; } + + IncomingAccountPackets.CompleteAccountLogin(state, e.Accepted, e.RejectReason); } [OnEvent(nameof(GameServer.GameServerLoginEvent))] @@ -342,7 +455,9 @@ public static class AccountHandler logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un); e.Accepted = false; } - else if (!acct.CheckPassword(pw)) + // The auth id was only issued after the account login packet verified this password, so + // re-deriving the hash costs a second Argon2 verify to answer the same question. + else if (!e.PreAuthenticated && !acct.CheckPassword(pw)) { logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un); e.Accepted = false; @@ -355,6 +470,7 @@ public static class AccountHandler else { acct.LogAccess(e.State); + LoginAllowlist.RecordLogin(e.State?.Address); logger.Information("Login: {NetState} Account '{Username}' at character list", e.State, un); e.State.Account = acct; diff --git a/Projects/UOContent/Accounting/IPasswordProtection.cs b/Projects/UOContent/Accounting/IPasswordProtection.cs index 0fdd6ced9..f5590a148 100644 --- a/Projects/UOContent/Accounting/IPasswordProtection.cs +++ b/Projects/UOContent/Accounting/IPasswordProtection.cs @@ -4,5 +4,12 @@ namespace Server.Accounting { string EncryptPassword(string plainPassword); bool ValidatePassword(string encryptedPassword, string plainPassword); + + /// + /// True when was produced with parameters that differ + /// from the ones this protection currently uses, so a successful login should rewrite it. + /// Algorithms whose cost is not embedded in the stored value never need this. + /// + bool NeedsRehash(string encryptedPassword) => false; } } diff --git a/Projects/UOContent/Accounting/Security/AccountSecurity.cs b/Projects/UOContent/Accounting/Security/AccountSecurity.cs index 9f7faeafc..deae4c8e0 100644 --- a/Projects/UOContent/Accounting/Security/AccountSecurity.cs +++ b/Projects/UOContent/Accounting/Security/AccountSecurity.cs @@ -51,6 +51,17 @@ public static class AccountSecurity } } + /// + /// The string actually fed to the KDF. SHA1 and SHA2 salt by username; everything else hashes + /// the password alone. Verification must derive with the algorithm the stored hash was made + /// with, and a rehash with the one it is moving to -- deriving with the wrong one produces a + /// hash that verifies once and never again. + /// + public static string DerivePhrase(PasswordProtectionAlgorithm algorithm, string username, string plainPassword) + => algorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2 + ? $"{username}{plainPassword}" + : plainPassword; + public static IPasswordProtection GetPasswordProtection(PasswordProtectionAlgorithm algorithm) { var passwordProtection = algorithm switch diff --git a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs index 99f320a78..0a952117d 100644 --- a/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/Argon2PasswordProtection.cs @@ -21,11 +21,37 @@ public class Argon2PasswordProtection : IPasswordProtection { public static IPasswordProtection Instance = new Argon2PasswordProtection(); - private readonly Argon2PasswordHasher m_PasswordHasher = new(rng: RandomNumberGenerator.Create()); + // 16 MiB at t=1 is cheaper than 8 MiB at t=3 (8.5 ms vs 10.1 ms) and twice as memory-hard, which + // is what resists GPU and ASIC cracking. p=1: native argon2 spawns a thread per lane. + private readonly Argon2PasswordHasher _passwordHasher = new( + time: 1, + memory: 16384, + parallel: 1, + type: Argon2Type.Argon2id, + rng: RandomNumberGenerator.Create() + ); public string EncryptPassword(string plainPassword) => - m_PasswordHasher.Hash(plainPassword); + _passwordHasher.Hash(plainPassword); public bool ValidatePassword(string encryptedPassword, string plainPassword) => - m_PasswordHasher.Verify(encryptedPassword, plainPassword); + _passwordHasher.Verify(encryptedPassword, plainPassword); + + // Verification uses the parameters embedded in the PHC string, not the configured ones, so + // comparing them is what lets a parameter change reach existing accounts. + public bool NeedsRehash(string encryptedPassword) + { + // Unparseable but verified: a format this build does not understand, so rewrite it. + if (!Argon2PasswordHasher.TryExtractMetadataValues(encryptedPassword, out var values)) + { + return true; + } + + return values.ArgonType != _passwordHasher.ArgonType + || values.MemoryCost != _passwordHasher.MemoryCost + || values.TimeCost != _passwordHasher.TimeCost + || values.Parallelism != _passwordHasher.Parallelism + || values.HashLength != (int)_passwordHasher.HashLength + || values.SaltLength != (int)_passwordHasher.SaltLength; + } } diff --git a/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs b/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs index 6d6e579cd..63d0ac7a8 100644 --- a/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/HashAlgorithmPasswordProtection.cs @@ -19,19 +19,47 @@ using Server.Text; namespace Server.Accounting.Security; +/// +/// The obsolete unsalted digests, kept only so imported accounts can log in once and be upgraded. +/// +/// Hashing goes through the one-shot static APIs rather than a retained . +/// A instance carries the running digest across HashCore/HashFinal, so +/// two threads sharing one corrupt each other's result -- and these are process-wide singletons. +/// The static form has no such state, allocates nothing, and produces identical bytes. +/// public class HashAlgorithmPasswordProtection : IPasswordProtection { - public static IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(MD5.Create()); - public static IPasswordProtection SHA1Instance = new HashAlgorithmPasswordProtection(SHA1.Create()); - public static IPasswordProtection SHA2Instance = new HashAlgorithmPasswordProtection(SHA512.Create()); - private readonly HashAlgorithm _hashAlgorithm; + private enum Kind + { + MD5, + SHA1, + SHA512 + } - public HashAlgorithmPasswordProtection(HashAlgorithm hashAlgorithm) => _hashAlgorithm = hashAlgorithm; + public static readonly IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(Kind.MD5); + public static readonly IPasswordProtection SHA1Instance = new HashAlgorithmPasswordProtection(Kind.SHA1); + public static readonly IPasswordProtection SHA2Instance = new HashAlgorithmPasswordProtection(Kind.SHA512); + + private const int MaxDigestLength = 64; // SHA512, the largest of the three. + + private readonly Kind _kind; + + private HashAlgorithmPasswordProtection(Kind kind) => _kind = kind; public string EncryptPassword(string plainPassword) { var bytes = plainPassword.AsSpan(0, Math.Min(256, plainPassword.Length)).GetBytesAscii(); - return _hashAlgorithm.ComputeHash(bytes).ToHexString(); + + Span digest = stackalloc byte[MaxDigestLength]; + + var written = _kind switch + { + Kind.MD5 => MD5.HashData(bytes, digest), + Kind.SHA1 => SHA1.HashData(bytes, digest), + _ => SHA512.HashData(bytes, digest) + }; + + return digest[..written].ToHexString(); } public bool ValidatePassword(string encryptedPassword, string plainPassword) => diff --git a/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs b/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs index 7ae464021..96706c030 100644 --- a/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs +++ b/Projects/UOContent/Accounting/Security/PBKDF2PasswordProtection.cs @@ -22,23 +22,24 @@ namespace Server.Accounting.Security; public class PBKDF2PasswordProtection : IPasswordProtection { - private const ushort m_MinIterations = 1024; - private const ushort m_MaxIterations = 1536; - private const int m_SaltSize = 8; - private const int m_HashSize = 32; - private const int m_OutputSize = 2 + m_SaltSize + m_HashSize; + private const ushort MinIterations = 1024; + private const ushort MaxIterations = 1536; + private const int SaltSize = 8; + private const int HashSize = 32; + private const int OutputSize = 2 + SaltSize + HashSize; public static readonly IPasswordProtection Instance = new PBKDF2PasswordProtection(); public string EncryptPassword(string plainPassword) { - Span output = stackalloc byte[m_OutputSize]; - var iterations = Utility.RandomMinMax(m_MinIterations, m_MaxIterations); + Span output = stackalloc byte[OutputSize]; + + var iterations = RandomNumberGenerator.GetInt32(MinIterations, MaxIterations + 1); BinaryPrimitives.WriteUInt16LittleEndian(output[..2], (ushort)iterations); - var salt = output.Slice(2, m_SaltSize); + var salt = output.Slice(2, SaltSize); RandomNumberGenerator.Fill(salt); - var hash = output.Slice(2 + m_SaltSize, m_HashSize); + var hash = output.Slice(2 + SaltSize, HashSize); Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256); return output.ToHexString(); @@ -46,15 +47,15 @@ public class PBKDF2PasswordProtection : IPasswordProtection public bool ValidatePassword(string encryptedPassword, string plainPassword) { - Span encryptedBytes = stackalloc byte[m_OutputSize]; + Span encryptedBytes = stackalloc byte[OutputSize]; encryptedPassword.GetBytes(encryptedBytes); var iterations = BinaryPrimitives.ReadUInt16LittleEndian(encryptedBytes[..2]); - var salt = encryptedBytes.Slice(2, m_SaltSize); + var salt = encryptedBytes.Slice(2, SaltSize); - Span hash = stackalloc byte[m_HashSize]; + Span hash = stackalloc byte[HashSize]; Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256); - return hash.SequenceEqual(encryptedBytes[(m_SaltSize + 2)..]); + return hash.SequenceEqual(encryptedBytes[(SaltSize + 2)..]); } } diff --git a/Projects/UOContent/Accounting/Security/PasswordWorker.cs b/Projects/UOContent/Accounting/Security/PasswordWorker.cs new file mode 100644 index 000000000..83e9207f1 --- /dev/null +++ b/Projects/UOContent/Accounting/Security/PasswordWorker.cs @@ -0,0 +1,293 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: PasswordWorker.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Concurrent; +using System.Threading; +using Server.Logging; +using Server.Network; + +namespace Server.Accounting.Security; + +/// +/// Work handed to the password thread, which reads no game state and writes none. +/// +/// Verify and hash are independently optional: a login verifies and may rehash, an explicit change +/// only hashes. +/// +internal sealed class PasswordJob +{ + public Account Account; + + /// Ties the job to a connection. Null when the work is not gated on one, such as a + /// password change by an admin. + public NetState State; + + /// Hash to verify against, with . + public string StoredHash; + + /// Algorithm was written with. Both algorithms are resolved on + /// the loop; AccountSecurity.CurrentAlgorithm is mutable state the worker must not read. + public PasswordProtectionAlgorithm StoredAlgorithm; + + /// Phrase to verify, or null to skip verification. + public string VerifyPhrase; + + /// Phrase to hash, or null when nothing needs writing. + public string HashPhrase; + + public PasswordProtectionAlgorithm TargetAlgorithm; + + /// Runs on the game loop with the result. Free to touch game state. + public Action OnComplete; +} + +internal readonly struct PasswordOutcome +{ + /// True when no verification was asked for, or it succeeded. + public readonly bool Verified; + + /// The derived hash, or null when nothing was hashed or verification failed. + public readonly string Hash; + + public PasswordOutcome(bool verified, string hash) + { + Verified = verified; + Hash = hash; + } +} + +/// +/// Runs password hashing off the game loop. An Argon2 verify costs ~8.9 ms of frozen world per +/// login attempt, successful or not. +/// +/// Exactly one worker, and that is load-bearing three times over. It cannot cost the loop more than +/// an inline verify under any scheduling regime, because at worst it takes an equal share of one +/// core -- which is what lets the measurement hold on hardware we cannot inspect. It caps live +/// hashing arenas at one. And writes apply in dispatch order only because a single thread drains +/// FIFO, so a second would need ordering reintroduced. +/// +/// ~110 verifies/sec, which is ample: only loop time matters, not login latency. +/// +internal sealed class PasswordWorker +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(PasswordWorker)); + + /// + /// Backstop, not a flood defense. SentFirstPacket holds a connection to one pending + /// verify and the engine caps connections at 4096 (NetState.Network.cs), so this matches + /// that bound and can only trip if that invariant breaks. A cap low enough to blunt an attack + /// would reject real players first; flood defense belongs at the connection layer. + /// + private const int MaxPending = 4096; + + // Nothing signals the worker when a save freeze ends, so it re-checks on this interval -- but + // only while a save is in progress, never in steady state. + private const int SaveGatePollMs = 50; + + private static PasswordWorker _instance; + + // Needs a spare core to move work to, which a 1-2 core host does not have. Off in DEBUG, where + // logins are rare and the inline path is easier to follow. + internal static readonly bool Enabled = +#if DEBUG + false; +#else + Environment.ProcessorCount >= 4; +#endif + + private readonly Thread _thread; + private readonly AutoResetEvent _work = new(false); + private readonly ConcurrentQueue _queue = []; + + private int _pending; + private volatile bool _exit; + + private PasswordWorker() + { + _thread = new Thread(Execute) + { + IsBackground = true, + Name = "Password Worker" + }; + + _thread.Start(); + } + + private static PasswordWorker Instance => _instance ??= new PasswordWorker(); + + /// Queues a job. False when full, and the caller must then reject without verifying. + internal static bool TryEnqueue(PasswordJob job) => Instance.TryEnqueueCore(job); + + private bool TryEnqueueCore(PasswordJob job) + { + if (Volatile.Read(ref _pending) >= MaxPending) + { + return false; + } + + Interlocked.Increment(ref _pending); + _queue.Enqueue(job); + _work.Set(); + + return true; + } + + /// + /// Checked before each job, which bounds a save overlap to whichever hash was already running: + /// the freeze holds the loop, so nothing new can be queued during it. PendingSave counts too -- + /// the serialization threads are already awake and spinning on an empty queue by then. + /// + private static bool CanRunNow() => World.WorldState is WorldState.Running or WorldState.WritingSave; + + private void Execute() + { + while (!_exit) + { + if (_queue.IsEmpty) + { + // A kernel block at zero CPU. Set() during a hash leaves the event signalled, so a + // wake arriving mid-job is not lost. + _work.WaitOne(); + continue; + } + + if (!CanRunNow()) + { + _work.WaitOne(SaveGatePollMs); + continue; + } + + if (!_queue.TryDequeue(out var job)) + { + continue; + } + + Interlocked.Decrement(ref _pending); + + // Gone while it waited: skip it rather than hash for a verdict nobody receives. Running + // only goes true -> false, so a stale read wastes a hash but never skips a live one. A + // null State is a job with no connection to lose, and still runs. + if (job.State?.Running == false) + { + continue; + } + + PasswordOutcome outcome; + + try + { + outcome = Compute(job); + } + catch (Exception ex) + { + // A verdict must still come back, or the connection never gets a reply. + logger.Error(ex, "Password work failed for {Username}", job.Account?.Username); + outcome = new PasswordOutcome(false, null); + } + + Core.LoopContext.Post(() => Apply(job, outcome)); + } + } + + private static PasswordOutcome Compute(PasswordJob job) + { + if (job.VerifyPhrase != null && + !AccountSecurity.GetPasswordProtection(job.StoredAlgorithm) + .ValidatePassword(job.StoredHash, job.VerifyPhrase)) + { + return new PasswordOutcome(false, null); + } + + return new PasswordOutcome( + true, + job.HashPhrase == null + ? null : AccountSecurity.GetPasswordProtection(job.TargetAlgorithm).EncryptPassword(job.HashPhrase) + ); + } + + private static void Apply(PasswordJob job, PasswordOutcome outcome) + { + // Re-checked: a connection can drop while the result sits in the loop queue. + if (job.State?.Running == false) + { + return; + } + + if (outcome.Verified && outcome.Hash != null) + { + job.Account.ApplyPasswordWrite(outcome.Hash, job.TargetAlgorithm); + } + + job.OnComplete?.Invoke(job, outcome); + } + + /// + /// Sets a password, off the loop where available and inline otherwise, invoking + /// on the loop either way. + /// + /// Confirm from , not the call site: off-loop the write has not + /// happened when this returns. + /// + internal static void SetPassword(Account account, string plainPassword, Action onDone) + { + if (!Enabled) + { + account.SetPassword(plainPassword); + onDone?.Invoke(true); + return; + } + + var job = new PasswordJob + { + Account = account, + HashPhrase = account.GetRehashPhrase(plainPassword), + TargetAlgorithm = AccountSecurity.CurrentAlgorithm, + OnComplete = (_, outcome) => onDone?.Invoke(outcome.Hash != null) + }; + + if (!TryEnqueue(job)) + { + // Saturated. Unlike a login, a password change must not be dropped, so it pays the + // hash on the loop instead. + account.SetPassword(plainPassword); + onDone?.Invoke(true); + } + } + + /// Runs a job on the calling thread. The seam the tests drive. + internal static PasswordOutcome ComputeInline(PasswordJob job) => Compute(job); + + /// + /// Stops the worker on shutdown or crash. Pending jobs are dropped rather than finished: + /// nothing saves the world after this point, so a write applied here would reach no disk. + /// + /// Draining the loop context is not this type's business either. That belongs in the core + /// shutdown path, before subscriber events run -- a subscriber pumping the shared context would + /// execute other subscribers' work at an arbitrary point in the event order. + /// + internal static void Stop() => _instance?.StopThread(); + + // HandleClosed skips InvokeShutdown when the server crashed, so the crash path needs its own + // subscription. + internal static void OnCrashed(ServerCrashedEventArgs e) => Stop(); + + private void StopThread() + { + _exit = true; + _work.Set(); + _thread.Join(TimeSpan.FromSeconds(5)); + } +} diff --git a/Projects/UOContent/Commands/GenObjects.cs b/Projects/UOContent/Commands/GenObjects.cs deleted file mode 100644 index dfd6903a1..000000000 --- a/Projects/UOContent/Commands/GenObjects.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using Server.Json; -using Server.Logging; - -namespace Server.Commands; - -public static class GenObjects -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(GenObjects)); - - public static void Configure() - { - CommandSystem.Register("GenObjects", AccessLevel.Developer, GenObjects_OnCommand); - } - - [Usage("GenObjects")] - [Aliases("GenObjWeb")] - [Description("Generates the objects cache (index + detail chunks) and syncs categorization.json.")] - private static void GenObjects_OnCommand(CommandEventArgs e) - { - var baseDir = Core.BaseDirectory; - var categorizationPath = Path.Combine(baseDir, "Data", "categorization.json"); - var categorization = JsonConfig.Deserialize>(categorizationPath) ?? []; - var discovered = ObjectIntrospection.DiscoverConstructibleTypes(); - - var result = ObjectCacheGenerator.Generate(categorization, discovered); - - var objectsDir = Path.Combine(baseDir, "Data", "objects"); - var detailDir = Path.Combine(objectsDir, "detail"); - Directory.CreateDirectory(detailDir); - - JsonConfig.Serialize(Path.Combine(objectsDir, "index.json"), result.Index); - foreach (var (chunkKey, map) in result.Chunks) - { - JsonConfig.Serialize(Path.Combine(detailDir, $"{chunkKey}.json"), map); - } - - JsonConfig.Serialize(categorizationPath, result.UpdatedCategorization); - - e.Mobile.SendMessage( - $"Objects cache written: {result.Index.Objects.Count} objects, {result.Chunks.Count} chunks. " + - $"Appended {result.Report.Appended.Count} to Uncategorized, {result.Report.Orphaned.Count} orphaned." - ); - - if (result.Report.Appended.Count > 0) - { - logger.Information("Appended to Uncategorized: {Types}", string.Join(", ", result.Report.Appended)); - } - - if (result.Report.Orphaned.Count > 0) - { - logger.Warning("Orphaned categorization entries: {Types}", string.Join(", ", result.Report.Orphaned)); - } - } -} diff --git a/Projects/UOContent/Commands/Generic/Commands/Commands.cs b/Projects/UOContent/Commands/Generic/Commands/Commands.cs index 81d85fc0a..102d5a2aa 100644 --- a/Projects/UOContent/Commands/Generic/Commands/Commands.cs +++ b/Projects/UOContent/Commands/Generic/Commands/Commands.cs @@ -9,6 +9,7 @@ using Server.Items; using Server.Mobiles; using Server.Multis; using Server.Network; +using Server.Network.Bans; using Server.Spells; namespace Server.Commands.Generic @@ -1154,7 +1155,8 @@ namespace Server.Commands.Generic try { - AdminFirewall.Add(state.Address); + Firewall.Add(new SingleIpFirewallEntry(state.Address)); + BanChannel.Report(state.Address, TimeSpan.Zero, BanReasons.Manual); AddResponse("They have been firewalled."); } catch (Exception ex) diff --git a/Projects/UOContent/Commands/LoopStats.cs b/Projects/UOContent/Commands/LoopStats.cs new file mode 100644 index 000000000..012de68d8 --- /dev/null +++ b/Projects/UOContent/Commands/LoopStats.cs @@ -0,0 +1,117 @@ +#if EVENT_LOOP_PROFILING +using System; +using System.Globalization; +using System.IO; +using Server.Logging; + +namespace Server.Commands; + +/// +/// Reports the event-loop time decomposition recorded by . +/// Only compiled when the server is built with -p:EventLoopProfiling=true. +/// See dev-docs/debugging-event-loop.md for how to read the output. +/// +public static class LoopStats +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(LoopStats)); + + public static void Configure() + { + CommandSystem.Register("LoopStats", AccessLevel.Administrator, LoopStats_OnCommand); + } + + [Usage("LoopStats")] + [Description("Summarizes the last minute of event-loop time accounting and writes the full history to a CSV.")] + private static void LoopStats_OnCommand(CommandEventArgs e) + { + var history = EventLoopProfiler.History(); + if (history.Length == 0) + { + e.Mobile.SendMessage("No samples recorded yet."); + return; + } + + var window = Math.Min(60, history.Length); + + double wall = 0, sleep = 0, gc = 0, stolen = 0, stolenMax = 0; + long iterations = 0, sleeps = 0, lateWakes = 0, wheelLagMax = 0; + Span phases = stackalloc double[EventLoopProfiler.PhaseCount]; + Span phaseMax = stackalloc double[EventLoopProfiler.PhaseCount]; + + for (var i = history.Length - window; i < history.Length; i++) + { + ref var s = ref history[i]; + wall += s.WallMs; + sleep += s.SleepMs; + gc += s.GcPauseMs; + stolen += s.StolenMs; + iterations += s.Iterations; + sleeps += s.Sleeps; + lateWakes += s.LateWakes; + + if (s.StolenMs > stolenMax) + { + stolenMax = s.StolenMs; + } + + if (s.WheelLagMaxMs > wheelLagMax) + { + wheelLagMax = s.WheelLagMaxMs; + } + + for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) + { + phases[p] += s.Phases[p]; + if (s.Phases[p] > phaseMax[p]) + { + phaseMax[p] = s.Phases[p]; + } + } + } + + e.Mobile.SendMessage($"Loop, last {window}s of wall time {wall:F0}ms:"); + e.Mobile.SendMessage($" sleep {100 * sleep / wall:F1}%, gc {100 * gc / wall:F1}%, stolen {100 * stolen / wall:F1}% (worst {stolenMax:F0}ms/s)"); + + for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) + { + e.Mobile.SendMessage($" {(LoopPhase)p}: {100 * phases[p] / wall:F1}% (worst {phaseMax[p]:F0}ms/s)"); + } + + e.Mobile.SendMessage($" {iterations} iterations, {sleeps} sleeps, {lateWakes} late wakes, worst wheel lag {wheelLagMax}ms"); + + var path = Path.Combine(Core.BaseDirectory, $"loopstats-{Core.Now:yyyyMMdd-HHmmss}.csv"); + WriteCsv(path, history); + e.Mobile.SendMessage($"Full history ({history.Length} samples) written to {path}"); + logger.Information("Loop stats dumped to {Path}", path); + } + + private static void WriteCsv(string path, EventLoopProfiler.Sample[] history) + { + using var writer = new StreamWriter(path); + writer.Write("wallStart,wallMs,iterations,sleeps,sleepMs,sleepOvershootMaxMs,lateWakes,wheelLagMaxMs,wakesIssued,wakesElided,gcPauseMs,gen0,gen1,gen2,stolenMs"); + for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) + { + writer.Write(','); + writer.Write((LoopPhase)p); + } + + writer.WriteLine(); + + for (var i = 0; i < history.Length; i++) + { + ref var s = ref history[i]; + writer.Write(string.Create( + CultureInfo.InvariantCulture, + $"{s.WallStart},{s.WallMs},{s.Iterations},{s.Sleeps},{s.SleepMs:F2},{s.SleepOvershootMaxMs:F2},{s.LateWakes},{s.WheelLagMaxMs},{s.WakesIssued},{s.WakesElided},{s.GcPauseMs:F2},{s.Gen0},{s.Gen1},{s.Gen2},{s.StolenMs:F2}" + )); + for (var p = 0; p < EventLoopProfiler.PhaseCount; p++) + { + writer.Write(','); + writer.Write(string.Create(CultureInfo.InvariantCulture, $"{s.Phases[p]:F2}")); + } + + writer.WriteLine(); + } + } +} +#endif diff --git a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs index 6b9c37c8b..cbded692c 100644 --- a/Projects/UOContent/Commands/Object Creation/CAGLoader.cs +++ b/Projects/UOContent/Commands/Object Creation/CAGLoader.cs @@ -29,175 +29,6 @@ public static class CAGLoader private static readonly ILogger logger = LogFactory.GetLogger(typeof(CAGLoader)); public static CAGCategory Load() - { - var indexPath = Path.Combine(Core.BaseDirectory, "Data/objects/index.json"); - - if (!File.Exists(indexPath)) - { - logger.Warning("objects/index.json missing — run [GenObjects. Falling back to live categorization load."); - return LoadLegacy(); - } - - var index = JsonConfig.Deserialize(indexPath); - if (index?.Objects == null) - { - throw new JsonException($"Failed to deserialize {indexPath}."); - } - - var root = BuildTree(index); - AddFallbackForStaleCache(root, index); - return root; - } - - public static CAGCategory BuildTree(ObjectIndexFile index) - { - var root = new CAGCategory("Add Menu"); - var pending = new Dictionary>(); - - foreach (var entry in index.Objects) - { - var type = AssemblyHandler.FindTypeByName(entry.Type); - if (type == null) - { - logger.Warning("Cached type {Type} no longer resolves; skipping.", entry.Type); - continue; - } - - var category = NavigateToCategory(root, entry.Category); - if (!pending.TryGetValue(category, out var objects)) - { - objects = new List(); - pending[category] = objects; - } - - objects.Add( - new CAGObject - { - Type = type, - ItemID = entry.ItemID, - Hue = entry.Hue == 0 ? null : entry.Hue, - Parent = category - } - ); - } - - foreach (var (category, objects) in pending) - { - AppendNodes(category, objects); - } - - return root; - } - - private static CAGCategory NavigateToCategory(CAGCategory root, string dotted) - { - var parent = root; - foreach (var name in dotted.Split('.')) - { - var child = FindCategory(parent, name); - if (child == null) - { - child = new CAGCategory(name, parent); - AppendNode(parent, child); - } - - parent = child; - } - - return parent; - } - - private static CAGCategory FindCategory(CAGCategory parent, string title) - { - if (parent.Nodes == null) - { - return null; - } - - foreach (var node in parent.Nodes) - { - if (node is CAGCategory cat && cat.Title == title) - { - return cat; - } - } - - return null; - } - - private static void AppendNode(CAGCategory parent, CAGNode node) - { - var nodes = parent.Nodes ?? []; - var grown = new CAGNode[nodes.Length + 1]; - Array.Copy(nodes, grown, nodes.Length); - grown[^1] = node; - parent.Nodes = grown; - } - - private static void AppendObject(CAGCategory category, CAGObject obj) => AppendNode(category, obj); - - private static void AppendNodes(CAGCategory parent, IReadOnlyList nodes) - { - var existing = parent.Nodes ?? []; - var grown = new CAGNode[existing.Length + nodes.Count]; - Array.Copy(existing, grown, existing.Length); - for (var i = 0; i < nodes.Count; i++) - { - grown[existing.Length + i] = nodes[i]; - } - - parent.Nodes = grown; - } - - private static void AddFallbackForStaleCache(CAGCategory root, ObjectIndexFile index) - { - var cached = new HashSet(); - foreach (var entry in index.Objects) - { - cached.Add(entry.Type); - } - - var categorizationPath = Path.Combine(Core.BaseDirectory, "Data/categorization.json"); - var categorization = JsonConfig.Deserialize>(categorizationPath); - if (categorization == null) - { - return; - } - - foreach (var cag in categorization) - { - foreach (var obj in cag.Objects ?? []) - { - if (obj.Type == null || cached.Contains(obj.Type.Name)) - { - continue; - } - - logger.Warning("objects cache stale for {Type} — run [GenObjects.", obj.Type.Name); - try - { - var lean = ObjectIntrospection.ExtractLean(obj.Type); - var category = NavigateToCategory(root, cag.Category); - AppendObject( - category, - new CAGObject - { - Type = obj.Type, - ItemID = lean.ItemID, - Hue = lean.Hue == 0 ? null : lean.Hue, - Parent = category - } - ); - } - catch (Exception ex) - { - logger.Warning(ex, "Failed live fallback for {Type}.", obj.Type.Name); - } - } - } - } - - private static CAGCategory LoadLegacy() { var root = new CAGCategory("Add Menu"); var path = Path.Combine(Core.BaseDirectory, "Data/categorization.json"); diff --git a/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs b/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs deleted file mode 100644 index 90b8f0169..000000000 --- a/Projects/UOContent/Commands/Object Creation/CategorizationSync.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Server.Items; - -namespace Server.Commands; - -public sealed record SyncReport(List Appended, List Orphaned); - -public static class CategorizationSync -{ - public static (List updated, SyncReport report) Reconcile( - List categorization, IReadOnlyList discovered - ) - { - var categorizedTypes = new HashSet(); - foreach (var cag in categorization) - { - foreach (var obj in cag.Objects ?? []) - { - if (obj.Type != null) - { - categorizedTypes.Add(obj.Type); - } - } - } - - var discoveredSet = new HashSet(discovered); - - var orphaned = categorizedTypes - .Where(t => !discoveredSet.Contains(t)) - .Select(t => t.Name) - .OrderBy(n => n, StringComparer.Ordinal) - .ToList(); - - var updated = new List(categorization); - var appended = new List(); - var itemAppend = new List(); - var mobileAppend = new List(); - - foreach (var type in discovered) - { - if (categorizedTypes.Contains(type)) - { - continue; - } - - appended.Add(type.Name); - var target = typeof(Mobile).IsAssignableFrom(type) ? mobileAppend : itemAppend; - target.Add(new CAGObject { Type = type }); - } - - AppendUncategorized(updated, "Items.Uncategorized", itemAppend); - AppendUncategorized(updated, "Mobiles.Uncategorized", mobileAppend); - - return (updated, new SyncReport(appended, orphaned)); - } - - private static void AppendUncategorized(List updated, string category, List toAdd) - { - if (toAdd.Count == 0) - { - return; - } - - var existing = updated.FirstOrDefault(c => c.Category == category); - if (existing == null) - { - updated.Add(new CAGJson { Category = category, Objects = toAdd.ToArray() }); - return; - } - - var merged = new List(existing.Objects ?? []); - merged.AddRange(toAdd); - updated[updated.IndexOf(existing)] = existing with { Objects = merged.ToArray() }; - } -} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs deleted file mode 100644 index 0ce79145f..000000000 --- a/Projects/UOContent/Commands/Object Creation/ObjectCacheBuilder.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Server.Commands; - -public sealed record ExtractedObject( - Type Type, - string Entity, - string Category, - LeanMetadata Lean, - List Ctors, - List Properties, - List Opl, - string BaseType -); - -public static class ObjectCacheBuilder -{ - public static (ObjectIndexFile index, Dictionary> chunks) Build( - IReadOnlyList objects, string generatedUtc - ) - { - var index = new ObjectIndexFile { GeneratedUtc = generatedUtc }; - var chunks = new Dictionary>(); - - foreach (var obj in objects) - { - var chunkKey = ObjectNaming.ChunkKey(obj.Category); - - index.Objects.Add( - new ObjectIndexEntry - { - Type = obj.Type.Name, - Entity = obj.Entity, - Category = obj.Category, - Chunk = chunkKey, - ItemID = obj.Lean.ItemID, - Hue = obj.Lean.Hue, - Name = obj.Lean.Name, - Cliloc = obj.Lean.Cliloc - } - ); - - if (!chunks.TryGetValue(chunkKey, out var chunk)) - { - chunks[chunkKey] = chunk = new Dictionary(); - } - - chunk[obj.Type.Name] = new ObjectDetail - { - BaseType = obj.BaseType, - Ctors = obj.Ctors, - Properties = obj.Properties, - Opl = obj.Opl - }; - } - - return (index, chunks); - } -} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs b/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs deleted file mode 100644 index 1f7db92b6..000000000 --- a/Projects/UOContent/Commands/Object Creation/ObjectCacheGenerator.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using Server.Logging; - -namespace Server.Commands; - -public sealed record ObjectCacheResult( - ObjectIndexFile Index, - Dictionary> Chunks, - List UpdatedCategorization, - SyncReport Report -); - -public static class ObjectCacheGenerator -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(ObjectCacheGenerator)); - - public static ObjectCacheResult Generate(List categorization, IReadOnlyList discovered) - { - var (updatedCategorization, report) = CategorizationSync.Reconcile(categorization, discovered); - - var categoryByType = new Dictionary(); - foreach (var cag in updatedCategorization) - { - foreach (var obj in cag.Objects ?? []) - { - if (obj.Type != null) - { - categoryByType[obj.Type] = cag.Category; - } - } - } - - var generatedUtc = DateTime.UtcNow.ToString("O"); - var extracted = new List(); - foreach (var type in discovered) - { - try - { - var entity = typeof(Mobile).IsAssignableFrom(type) ? "mobile" : "item"; - var category = categoryByType.TryGetValue(type, out var c) - ? c - : entity == "mobile" ? "Mobiles.Uncategorized" : "Items.Uncategorized"; - - extracted.Add( - new ExtractedObject( - type, - entity, - category, - ObjectIntrospection.ExtractLean(type), - ObjectIntrospection.ExtractCtors(type), - ObjectIntrospection.ExtractProperties(type), - ObjectIntrospection.ExtractOpl(type), - type.BaseType?.Name ?? "object" - ) - ); - } - catch (Exception ex) - { - logger.Warning(ex, "Failed to introspect {Type}; skipping.", type); - } - } - - var (index, chunks) = ObjectCacheBuilder.Build(extracted, generatedUtc); - return new ObjectCacheResult(index, chunks, updatedCategorization, report); - } -} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs b/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs deleted file mode 100644 index 8f18dd8fc..000000000 --- a/Projects/UOContent/Commands/Object Creation/ObjectDocs.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Server.Commands; - -public static class ObjectNaming -{ - public static string ChunkKey(string category) => category.ToLowerInvariant().Replace(' ', '-'); - - public static string FriendlyTypeName(Type t) - { - if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Nullable<>)) - { - return $"{FriendlyTypeName(Nullable.GetUnderlyingType(t))}?"; - } - - return t switch - { - _ when t == typeof(int) => "int", - _ when t == typeof(uint) => "uint", - _ when t == typeof(bool) => "bool", - _ when t == typeof(string) => "string", - _ when t == typeof(double) => "double", - _ when t == typeof(float) => "float", - _ when t == typeof(long) => "long", - _ when t == typeof(ulong) => "ulong", - _ when t == typeof(short) => "short", - _ when t == typeof(ushort) => "ushort", - _ when t == typeof(byte) => "byte", - _ when t == typeof(sbyte) => "sbyte", - _ when t == typeof(char) => "char", - _ when t == typeof(decimal) => "decimal", - _ => t.Name - }; - } -} - -public sealed class ObjectIndexEntry -{ - [JsonPropertyName("type")] public string Type { get; set; } - [JsonPropertyName("entity")] public string Entity { get; set; } - [JsonPropertyName("category")] public string Category { get; set; } - [JsonPropertyName("chunk")] public string Chunk { get; set; } - [JsonPropertyName("gfx")] public int ItemID { get; set; } - [JsonPropertyName("hue")] public int Hue { get; set; } - [JsonPropertyName("name")] public string Name { get; set; } - [JsonPropertyName("cliloc")] public int? Cliloc { get; set; } -} - -public sealed class ObjectIndexFile -{ - [JsonPropertyName("generatedUtc")] public string GeneratedUtc { get; set; } - [JsonPropertyName("objects")] public List Objects { get; set; } = []; -} - -public sealed class ParamDoc -{ - [JsonPropertyName("name")] public string Name { get; set; } - [JsonPropertyName("type")] public string Type { get; set; } - [JsonPropertyName("default")] public string Default { get; set; } - [JsonPropertyName("isParams")] public bool IsParams { get; set; } -} - -public sealed class CtorDoc -{ - [JsonPropertyName("parameters")] public List Parameters { get; set; } = []; -} - -public sealed class PropertyDoc -{ - [JsonPropertyName("name")] public string Name { get; set; } - [JsonPropertyName("type")] public string Type { get; set; } - [JsonPropertyName("readLevel")] public string ReadLevel { get; set; } - [JsonPropertyName("writeLevel")] public string WriteLevel { get; set; } - [JsonPropertyName("readOnly")] public bool ReadOnly { get; set; } - [JsonPropertyName("enumValues")] public string[] EnumValues { get; set; } -} - -public sealed class OplLine -{ - [JsonPropertyName("cliloc")] public int Cliloc { get; set; } - [JsonPropertyName("args")] public string Args { get; set; } - [JsonPropertyName("text")] public string Text { get; set; } -} - -public sealed class ObjectDetail -{ - [JsonPropertyName("baseType")] public string BaseType { get; set; } - [JsonPropertyName("ctors")] public List Ctors { get; set; } = []; - [JsonPropertyName("properties")] public List Properties { get; set; } = []; - [JsonPropertyName("opl")] public List Opl { get; set; } = []; -} diff --git a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs b/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs deleted file mode 100644 index f0a11b31c..000000000 --- a/Projects/UOContent/Commands/Object Creation/ObjectIntrospection.cs +++ /dev/null @@ -1,228 +0,0 @@ -using System; -using System.Buffers.Binary; -using System.Collections.Generic; -using System.Reflection; -using System.Text; -using Server.Items; - -namespace Server.Commands; - -public readonly record struct LeanMetadata(int ItemID, int Hue, string Name, int? Cliloc); - -public static class ObjectIntrospection -{ - public static LeanMetadata ExtractLean(Type type) - { - if (type.IsAssignableTo(typeof(Item))) - { - var item = type.CreateInstance(); - try - { - var itemID = item.ItemID; - if (item is BaseAddon addon && addon.Components.Count == 1) - { - itemID = addon.Components[0].ItemID; - } - - if (itemID > TileData.MaxItemValue) - { - itemID = 1; - } - - var hue = item.Hue & 0x7FFF; - hue = (hue & 0x4000) != 0 ? 0 : hue; - - var cliloc = item.LabelNumber > 0 ? item.LabelNumber : (int?)null; - var name = item.Name ?? (cliloc.HasValue ? Server.Localization.GetText(cliloc.Value) : null); - - return new LeanMetadata(itemID, hue, name, cliloc); - } - finally - { - item.Delete(); - } - } - - if (type.IsAssignableTo(typeof(Mobile))) - { - var m = type.CreateInstance(); - try - { - var itemID = ShrinkTable.Lookup(m, 1); - var hue = m.Hue & 0x7FFF; - hue = (hue & 0x4000) != 0 ? 0 : hue; - return new LeanMetadata(itemID, hue, m.Name, null); - } - finally - { - m.Delete(); - } - } - - throw new ArgumentException($"{type} is neither Item nor Mobile.", nameof(type)); - } - - public static List ExtractCtors(Type type) - { - var docs = new List(); - - foreach (var ctor in type.GetConstructors()) - { - if (!Attributes.IsConstructible(ctor, AccessLevel.Developer)) - { - continue; - } - - var doc = new CtorDoc(); - foreach (var p in ctor.GetParameters()) - { - doc.Parameters.Add( - new ParamDoc - { - Name = p.Name, - Type = ObjectNaming.FriendlyTypeName(p.ParameterType), - Default = p.HasDefaultValue ? p.DefaultValue?.ToString() ?? "null" : null, - IsParams = p.IsDefined(typeof(ParamArrayAttribute), false) - } - ); - } - - docs.Add(doc); - } - - return docs; - } - - public static List ExtractProperties(Type type) - { - var docs = new List(); - - var props = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); - foreach (var p in props) - { - var attr = p.GetCustomAttribute(true); - if (attr == null) - { - continue; - } - - var pt = p.PropertyType; - docs.Add( - new PropertyDoc - { - Name = p.Name, - Type = ObjectNaming.FriendlyTypeName(pt), - ReadLevel = attr.ReadLevel.ToString(), - WriteLevel = attr.WriteLevel.ToString(), - ReadOnly = attr.ReadOnly || !p.CanWrite, - EnumValues = pt.IsEnum ? Enum.GetNames(pt) : null - } - ); - } - - return docs; - } - - public static List ExtractOpl(Type type) - { - if (type.IsAssignableTo(typeof(Item))) - { - var item = type.CreateInstance(); - try - { - var opl = new ObjectPropertyList(item); - item.GetProperties(opl); - return DecodeOpl(opl); - } - finally - { - item.Delete(); - } - } - - if (type.IsAssignableTo(typeof(Mobile))) - { - var m = type.CreateInstance(); - try - { - var opl = new ObjectPropertyList(m); - m.GetProperties(opl); - return DecodeOpl(opl); - } - finally - { - m.Delete(); - } - } - - return []; - } - - private static List DecodeOpl(ObjectPropertyList opl) - { - opl.Terminate(); - var buffer = opl.Buffer; - var lines = new List(); - var pos = 15; // fixed OPL header length - - while (pos + 4 <= buffer.Length) - { - var cliloc = BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(pos)); - pos += 4; - if (cliloc == 0) - { - break; - } - - var byteLen = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(pos)); - pos += 2; - var args = byteLen > 0 ? Encoding.Unicode.GetString(buffer, pos, byteLen) : null; - pos += byteLen; - - lines.Add(new OplLine { Cliloc = cliloc, Args = args, Text = Localization.GetText(cliloc) }); - } - - return lines; - } - - public static List DiscoverConstructibleTypes() - { - var results = new List(); - - foreach (var asm in AssemblyHandler.Assemblies) - { - foreach (var type in AssemblyHandler.GetTypeCache(asm).Types) - { - if (type.IsAbstract) - { - continue; - } - - if (!typeof(Item).IsAssignableFrom(type) && !typeof(Mobile).IsAssignableFrom(type)) - { - continue; - } - - if (HasConstructibleCtor(type)) - { - results.Add(type); - } - } - } - - return results; - } - - private static bool HasConstructibleCtor(Type type) - { - foreach (var ctor in type.GetConstructors()) - { - if (Attributes.IsConstructible(ctor, AccessLevel.Developer)) - { - return true; - } - } - - return false; - } -} diff --git a/Projects/UOContent/Commands/VisibilityList.cs b/Projects/UOContent/Commands/VisibilityList.cs index 3e4a0dc2a..ddad258c2 100644 --- a/Projects/UOContent/Commands/VisibilityList.cs +++ b/Projects/UOContent/Commands/VisibilityList.cs @@ -103,9 +103,8 @@ namespace Server.Commands { var list = pm.VisibilityList; - if (list.Contains(targ)) + if (list.Remove(targ)) { - list.Remove(targ); pm.SendMessage($"{targ.Name} has been removed from your visibility list."); } else diff --git a/Projects/UOContent/Configuration/ExpansionConfiguration.cs b/Projects/UOContent/Configuration/ExpansionConfiguration.cs index 3ae24bbca..50fec1dc3 100644 --- a/Projects/UOContent/Configuration/ExpansionConfiguration.cs +++ b/Projects/UOContent/Configuration/ExpansionConfiguration.cs @@ -7,7 +7,6 @@ namespace Server { public static void Configure() { - Mobile.InsuranceEnabled = ServerConfiguration.GetSetting("insurance.enable", Core.AOS); ObjectPropertyList.Enabled = ServerConfiguration.GetSetting("opl.enable", Core.AOS); var visibleDamage = ServerConfiguration.GetSetting("visibleDamage", Core.AOS); Mobile.VisibleDamageType = visibleDamage ? VisibleDamageType.Related : VisibleDamageType.None; diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs index 58a7f73a9..7f72c02b2 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchGump.cs @@ -8,6 +8,7 @@ using Server.Commands; using Server.Commands.Generic; using Server.Engines.Spawners; using Server.Gumps; +using Server.Logging; using Server.Network; using Server.Saves; @@ -28,11 +29,19 @@ public enum AdvancedSearchGumpOptions : long public class AdvancedSearchGump : Gump { + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchGump)); + private const int MaxEntries = 18; private static int _threadId; private static AdvancedSearchThreadWorker[] _threadWorkers; + private static int _searchInProgress; + + internal static bool IsSearchInProgress => Volatile.Read(ref _searchInProgress) == 1; + internal static bool TryBeginSearch() => Interlocked.CompareExchange(ref _searchInProgress, 1, 0) == 0; + internal static void EndSearch() => Volatile.Write(ref _searchInProgress, 0); + private static void Configure() { EventSink.Shutdown += Shutdown; @@ -124,6 +133,10 @@ public class AdvancedSearchGump : Gump public AdvancedSearchGump() : base(50, 50) => Build(); + // Entries on the current page — bounds the paging loop so a partial last page can't read past the end. + internal static int VisibleCount(int total, int displayFrom, int maxEntries) => + Math.Clamp(total - displayFrom, 0, maxEntries); + private void Build() { const int height = 500; @@ -319,15 +332,13 @@ public class AdvancedSearchGump : Gump var allDisplayedSelected = true; - for (var i = 0; i < MaxEntries; i++) - { - var offset = SortDescending ? MaxEntries - 1 - i : i; - var index = offset + DisplayFrom; + // Bound to this page's real entries so a partial last page still renders in descending mode. + var visibleCount = VisibleCount(SearchResults.Length, DisplayFrom, MaxEntries); - if (index >= SearchResults.Length) - { - break; - } + for (var i = 0; i < visibleCount; i++) + { + var offset = SortDescending ? visibleCount - 1 - i : i; + var index = offset + DisplayFrom; var entry = SearchResults[index]; @@ -739,78 +750,103 @@ public class AdvancedSearchGump : Gump return; } + if (!TryBeginSearch()) + { + from.SendMessage("A search is already running. Please wait for it to finish."); + return; + } + + _threadId = 0; + var autoSave = AutoSave.SavesEnabled; - if (autoSave) + AutoSave.SavesEnabled = false; + + try { - AutoSave.SavesEnabled = false; - } + _threadWorkers ??= new AdvancedSearchThreadWorker[Math.Max(Environment.ProcessorCount - 1, 1)]; - _threadWorkers ??= new AdvancedSearchThreadWorker[Math.Max(Environment.ProcessorCount - 1, 1)]; + var ignoreQueue = new ConcurrentQueue(); + var results = new ConcurrentQueue(); + var worldLocation = new WorldLocation(from.Location, from.Map); - var ignoreQueue = new ConcurrentQueue(); - var results = new ConcurrentQueue(); - var worldLocation = new WorldLocation(from.Location, from.Map); - - for (var i = 0; i < _threadWorkers.Length; i++) - { - (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue); - } - - var type = Filter.FilterType ? Filter.Type : null; - - // Push the entities - foreach (var item in World.Items.Values) - { - if (type == null || type.IsInstanceOfType(item)) - { - PushToWorkers(item); - } - } - - foreach (var m in World.Mobiles.Values) - { - if (type == null || type.IsInstanceOfType(m)) - { - PushToWorkers(m); - } - } - - ThreadPool.QueueUserWorkItem(state => - { - // Block until everything is processed for (var i = 0; i < _threadWorkers.Length; i++) { - _threadWorkers[i].Sleep(); + (_threadWorkers[i] ??= new AdvancedSearchThreadWorker()).Wake(worldLocation, Filter, results, ignoreQueue); } - var ignoredEntities = new HashSet(ignoreQueue); + var type = Filter.FilterType ? Filter.Type : null; - // Force the GC to collect the ignored entities - ignoreQueue.Clear(); - - var resultsList = new List(results.Count); - foreach (var result in results) + // Push the entities. Workers read entity state concurrently with the main loop — + // see AdvancedSearchThreadWorker for the accepted, bounded race. + foreach (var item in World.Items.Values) { - if (!ignoredEntities.Contains(result.Entity)) + if (type == null || type.IsInstanceOfType(item)) { - resultsList.Add(result); + PushToWorkers(item); } } - SearchResults = resultsList.ToArray(); - - // Force the GC to collect the results - resultsList.Clear(); - resultsList.TrimExcess(); - - // Send the gump on the main thread - Core.LoopContext.Post( - autoSaveState => + foreach (var m in World.Mobiles.Values) + { + if (type == null || type.IsInstanceOfType(m)) { - AutoSave.SavesEnabled = (bool)autoSaveState!; - Resend(from); - }, state); - }, autoSave); + PushToWorkers(m); + } + } + + ThreadPool.QueueUserWorkItem(state => + { + try + { + // Block until everything is processed + for (var i = 0; i < _threadWorkers.Length; i++) + { + _threadWorkers[i].Sleep(); + } + + var ignoredEntities = new HashSet(ignoreQueue); + + // Force the GC to collect the ignored entities + ignoreQueue.Clear(); + + var resultsList = new List(results.Count); + foreach (var result in results) + { + if (!ignoredEntities.Contains(result.Entity)) + { + resultsList.Add(result); + } + } + + SearchResults = resultsList.ToArray(); + + // Force the GC to collect the results + resultsList.Clear(); + resultsList.TrimExcess(); + + // Send the gump on the main thread + Core.LoopContext.Post(() => Resend(from)); + } + catch (Exception ex) + { + // A drain-phase throw here would terminate the process; the finally still + // restores autosave and releases the guard. + _logger.Warning(ex, "AdvancedSearch: search drain failed"); + } + finally + { + AutoSave.SavesEnabled = (bool)state!; + EndSearch(); + } + }, autoSave); + } + catch + { + // Setup failed before the work item took ownership of the release. + AutoSave.SavesEnabled = autoSave; + EndSearch(); + throw; + } } private void SetSortSwitches(int radioSwitch) diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs index d63c0a442..5206477c9 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchThreadWorker.cs @@ -3,13 +3,26 @@ using System.Collections.Concurrent; using System.Reflection; using System.Threading; using Server.Items; +using Server.Logging; using Server.Mobiles; using Server.Multis; namespace Server.Engines.AdvancedSearch; +/// +/// Filters entities on a background thread while the main loop keeps mutating them — an +/// intentional, bounded race. Reads of live / state are +/// unsynchronized, so a torn read may report stale coordinates, and any +/// getter that throws mid-read is caught per-entity in and skipped. +/// Results are best-effort and may omit a concurrently modified entity, but never fault or corrupt +/// server state. Eliminating the race would require snapshotting each read field onto the main +/// thread before handing entities off; that is deferred. +/// public class AdvancedSearchThreadWorker { + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(AdvancedSearchThreadWorker)); + private static readonly ConcurrentDictionary _propCache = new(); + private readonly Thread _thread; private readonly AutoResetEvent _startEvent; // Main thread tells the thread to start working private readonly AutoResetEvent _stopEvent; // Main thread waits for the worker finish draining @@ -26,7 +39,10 @@ public class AdvancedSearchThreadWorker _startEvent = new AutoResetEvent(false); _stopEvent = new AutoResetEvent(false); _entities = new ConcurrentQueue(); - _thread = new Thread(Execute); + _thread = new Thread(Execute) + { + IsBackground = true + }; _thread.Start(this); } @@ -52,10 +68,16 @@ public class AdvancedSearchThreadWorker public void Exit() { - _exit = true; + Volatile.Write(ref _exit, true); Wake(WorldLocation.Zero, null, null, null); - Sleep(); + + // Tolerate a worker that has already terminated (e.g. Core.Closing raced us) so + // shutdown can't deadlock waiting on a stopEvent that will never be set. + if (_thread.IsAlive) + { + Sleep(); + } } public void Push(IEntity entity) @@ -88,12 +110,22 @@ public class AdvancedSearchThreadWorker worker._filter = null; break; } + else + { + // Transiently empty but not yet paused: yield rather than busy-spin. + Thread.Yield(); + } } - worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished - worker._pause = false; + // The owning thread may start another cycle the moment _stopEvent is set (Exit does exactly + // that). Clear _pause and sample the exit condition before signaling, or the new cycle's + // pause request is clobbered / its Sleep orphaned. Matches SerializationThreadWorker. + var exiting = Core.Closing || Volatile.Read(ref worker._exit); + Volatile.Write(ref worker._pause, false); - if (Core.Closing || worker._exit) + worker._stopEvent.Set(); // Allow the main thread to continue now that we are finished + + if (exiting) { return; } @@ -102,9 +134,28 @@ public class AdvancedSearchThreadWorker private AdvancedSearchResult DoEntitySearch(IEntity entity) { - if (_filter.HideValidInternalMap) + if (entity == null || entity.Deleted) { - HandleValidInternal(entity); + return null; + } + + try + { + return DoEntitySearchCore(entity); + } + catch (Exception ex) + { + _logger.Warning(ex, "AdvancedSearch: filter threw for {Entity}; skipping", entity); + return null; + } + } + + private AdvancedSearchResult DoEntitySearchCore(IEntity entity) + { + if (_filter == null) + { + // Exit() clears the filter; a straggler entity dequeued after teardown bails here. + return null; } // Check for valid map @@ -138,6 +189,12 @@ public class AdvancedSearchThreadWorker return null; } + // After the cheap filters, so non-qualifying entities skip the house/keyring enumeration. + if (_filter.HideValidInternalMap) + { + HandleValidInternal(entity); + } + if (entity is Mobile mobile) { return DoMobileSearch(mobile); @@ -296,27 +353,17 @@ public class AdvancedSearchThreadWorker } } - private static bool EvaluateRecursive(IEntity entity, ReadOnlySpan span) - { - var atIndex = span.IndexOf('@'); - var orIndex = span.IndexOf('|'); - - if (atIndex == -1 && orIndex == -1) - { - return EvaluateSingleExpression(entity, span); - } - - var result = atIndex != -1; - var splitIndex = result ? atIndex : orIndex; - - var left = EvaluateRecursive(entity, span.Slice(0, splitIndex)); - var right = EvaluateRecursive(entity, span.Slice(splitIndex + 1)); - - return result ? left && right : left || right; - } + private static bool EvaluateRecursive(IEntity entity, ReadOnlySpan span) => + AdvancedSearchUtilities.EvaluateBoolean(span, entity, static (e, leaf) => EvaluateSingleExpression(e, leaf)); private static bool EvaluateSingleExpression(IEntity entity, ReadOnlySpan expression) { + expression = expression.Trim(); + if (expression.Length == 0) + { + return false; + } + var negate = false; if (expression[0] == '~') { @@ -338,7 +385,7 @@ public class AdvancedSearchThreadWorker return false; } - var properties = entity.GetType().GetProperties(); + var properties = _propCache.GetOrAdd(entity.GetType(), static t => t.GetProperties()); PropertyInfo property = null; for (var i = 0; i < properties.Length; ++i) { diff --git a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs index 8d7dae3c4..6775dcaef 100644 --- a/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs +++ b/Projects/UOContent/Engines/Advanced Search/AdvancedSearchUtilities.cs @@ -1,6 +1,5 @@ using System; using System.Buffers; -using System.Collections.Generic; using System.Globalization; using System.Numerics; using System.Runtime.CompilerServices; @@ -43,77 +42,80 @@ public static class AdvancedSearchUtilities if (propertyType == typeof(long)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((long)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((long)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(ulong)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((ulong)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(int)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((int)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((int)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(uint)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((uint)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(short)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((short)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((short)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(ushort)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((ushort)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(sbyte)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((sbyte)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(byte)) { - var parsedValue = ParseValue(valuePart); - return CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + CompareNumeric((byte)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(float)) { - var parsedValue = ParseValue(valuePart); - return Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((float)propertyValue!, parsedValue, valuePart, operatorSpan); } if (propertyType == typeof(double)) { - var parsedValue = ParseValue(valuePart); - return Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((double)propertyValue!, parsedValue, valuePart, operatorSpan); } if (propertyType == typeof(string)) { - var parsedValue = ParseValue(valuePart); - return Compare((string)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((string)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(TimeSpan)) { - var parsedValue = ParseValue(valuePart); - return Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((TimeSpan)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(DateTime)) { - var parsedValue = ParseValue(valuePart); - return Compare((DateTime)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((DateTime)propertyValue!, parsedValue, operatorSpan); } if (propertyType == typeof(bool)) { - var parsedValue = ParseValue(valuePart); - return Compare((bool)propertyValue!, parsedValue, operatorSpan); + return TryParseValue(valuePart, out var parsedValue) && + Compare((bool)propertyValue!, parsedValue, operatorSpan); } if (propertyType.IsEnum) { - var valueEnum = Enum.Parse(propertyType, valuePart, false); + if (!Enum.TryParse(propertyType, valuePart.ToString(), true, out var valueEnum) || valueEnum == null) + { + return false; + } return GetEnumSize(propertyType) switch { @@ -121,15 +123,17 @@ public static class AdvancedSearchUtilities 2 => CompareNumeric((short)propertyValue!, (short)valueEnum, operatorSpan), 4 => CompareNumeric((int)propertyValue!, (int)valueEnum, operatorSpan), 8 => CompareNumeric((long)propertyValue!, (long)valueEnum, operatorSpan), + _ => false }; } - if (!propertyType.IsValueType) - { - var parsedValue = ParseValue(valuePart); - return CompareReference(propertyValue!, parsedValue, operatorSpan); - } - - return false; + // Anything the hot typed paths above didn't handle — reference types (Poison, Map, entity + // properties resolved by serial), IParsable value types (Guid, decimal, ...), and legacy + // RunUO types with a static Parse(string) (Faction, Town, ...). Delegate to the shared, + // thread-safe Types converter so the target is parsed into the property's real type, then + // compare by value. A string is allocated here, but this is the uncommon path; the common + // types never reach it. Types returns a non-null message when it can't parse -> no match. + return Types.TryParse(propertyType, valuePart.ToString(), out var parsed) == null && + CompareReference(propertyValue!, parsed, operatorSpan); } public static bool CompareNumeric(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) where T : INumber => @@ -236,19 +240,40 @@ public static class AdvancedSearchUtilities _ => false }; - public static bool CompareReference(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) => - operatorSpan switch + public static bool CompareReference(T propertyValue, T parsedValue, ReadOnlySpan operatorSpan) + { + switch (operatorSpan) { - "=" or "==" => propertyValue.Equals(parsedValue), - "!" or "!=" => !propertyValue.Equals(parsedValue), - ">" => Comparer.Default.Compare(propertyValue, parsedValue) > 0, - "<" => Comparer.Default.Compare(propertyValue, parsedValue) < 0, - ">=" => Comparer.Default.Compare(propertyValue, parsedValue) >= 0, - "<=" => Comparer.Default.Compare(propertyValue, parsedValue) <= 0, - _ => false - }; + case "=": + case "==": return Equals(propertyValue, parsedValue); + case "!": + case "!=": return !Equals(propertyValue, parsedValue); + } - public static T ParseValue(ReadOnlySpan valuePart) + if (propertyValue is IComparable cmp && parsedValue != null) + { + try + { + var c = cmp.CompareTo(parsedValue); + return operatorSpan switch + { + ">" => c > 0, + "<" => c < 0, + ">=" => c >= 0, + "<=" => c <= 0, + _ => false + }; + } + catch + { + return false; + } + } + + return false; + } + + internal static bool TryParseValue(ReadOnlySpan valuePart, out T value) { // Special handling for boolean and hexadecimal values if (typeof(T) == typeof(bool)) @@ -256,74 +281,149 @@ public static class AdvancedSearchUtilities var val = valuePart.ToString().ToLower(); if (val is "true" or "1" or "enabled" or "on") { - return (T)(object)true; + value = (T)(object)true; + return true; } if (val is "false" or "0" or "disabled" or "off") { - return (T)(object)false; + value = (T)(object)false; + return true; } + + value = default; + return false; } if (typeof(T) == typeof(long)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(ulong)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(int)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(uint)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(short)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(ushort)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(sbyte)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(byte)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(float)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } if (typeof(T) == typeof(double)) { - return ParseNumericValue(valuePart); + return TryParseNumericValue(valuePart, out value); } - // Default parsing for other types - return (T)Convert.ChangeType(valuePart.ToString(), typeof(T)); + // string needs no parsing — the span itself is the value. + if (typeof(T) == typeof(string)) + { + value = (T)(object)valuePart.ToString(); + return true; + } + + // Remaining supported types (TimeSpan, DateTime) parse straight from the span via + // ISpanParsable — no allocation, no reflection, and unlike Convert.ChangeType it handles + // TimeSpan, which is not IConvertible and previously failed silently. + if (typeof(T) == typeof(TimeSpan)) + { + return TryParseSpanParsable(valuePart, out value); + } + + if (typeof(T) == typeof(DateTime)) + { + return TryParseSpanParsable(valuePart, out value); + } + + value = default; + return false; + } + + // Parses U (a value type exposing ISpanParsable) from the span and reinterprets it as T. The + // two type params mirror TryParseNumericValue: the caller dispatches on typeof(T), so U == T at + // every call site and the (T)(object) cast is always valid. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryParseSpanParsable(ReadOnlySpan valuePart, out T value) where U : ISpanParsable + { + if (U.TryParse(valuePart, null, out var parsed)) + { + value = (T)(object)parsed; + return true; + } + + value = default; + return false; } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static R ParseNumericValue(ReadOnlySpan valuePart) where T : INumber => - valuePart.StartsWith("0x") - ? (R)(object)T.Parse(valuePart[2..], NumberStyles.HexNumber, null) - : (R)(object)T.Parse(valuePart, null); + private static bool TryParseNumericValue(ReadOnlySpan valuePart, out R value) where T : INumber + { + var ok = valuePart.StartsWith("0x") + ? T.TryParse(valuePart[2..], NumberStyles.HexNumber, null, out var parsed) + : T.TryParse(valuePart, null, out parsed); + + if (ok) + { + value = (R)(object)parsed; + return true; + } + + value = default; + return false; + } + + // Evaluates one trimmed leaf atom against caller-supplied state. A custom delegate is required + // because ReadOnlySpan cannot be a Func<> type argument; passing state avoids a per-call + // capturing closure, so the recursion allocates neither a string nor a closure. + internal delegate bool LeafEvaluator(TState state, ReadOnlySpan leaf); + + // OR ('|') binds looser than AND ('@'); split on the outermost OR first, then AND. + internal static bool EvaluateBoolean(ReadOnlySpan expr, TState state, LeafEvaluator evalLeaf) + { + var orIndex = expr.IndexOf('|'); + if (orIndex != -1) + { + return EvaluateBoolean(expr[..orIndex], state, evalLeaf) || EvaluateBoolean(expr[(orIndex + 1)..], state, evalLeaf); + } + + var andIndex = expr.IndexOf('@'); + if (andIndex != -1) + { + return EvaluateBoolean(expr[..andIndex], state, evalLeaf) && EvaluateBoolean(expr[(andIndex + 1)..], state, evalLeaf); + } + + return evalLeaf(state, expr.Trim()); + } private static int GetEnumSize(Type enumType) => Type.GetTypeCode(Enum.GetUnderlyingType(enumType)) switch diff --git a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs index 962c3d8e3..f63e4f8d4 100644 --- a/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs +++ b/Projects/UOContent/Engines/Bulk Orders/Books/BOBFilter.cs @@ -6,27 +6,27 @@ namespace Server.Engines.BulkOrders; public partial class BOBFilter { [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeType))] private int _type; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeType() => _type != 0; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeQuality))] private int _quality; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeQuality() => _quality != 0; [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeMaterial))] private int _material; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeMaterial() => _material != 0; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeQuantity))] private int _quantity; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuantity() => _quantity != 0; private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs index 9ff8dc633..2632fd954 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSkullBrazier.cs @@ -33,17 +33,13 @@ public partial class ChampionSkullBrazier : AddonComponent [SerializedCommandProperty(AccessLevel.GameMaster)] private ChampionSkullPlatform _platform; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public Item Skull + [SerializableField(2, fieldChanged: nameof(OnSkullChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Item _skull; + + private void OnSkullChanged(Item oldValue, Item newValue) { - get => _skull; - set - { - _skull = value; - this.MarkDirty(); - _platform?.Validate(); - } + _platform?.Validate(); } public override int LabelNumber => 1049489 + (int)_type; diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index a26bcc0f9..9daea0ba3 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -27,16 +27,44 @@ using Server.Logging; namespace Server.Engines.CannedEvil; -[SerializationGenerator(10, false)] +[SerializationGenerator(11, false)] public partial class ChampionSpawn : Item { + private void MigrateFrom(V10Content content) + { + _level = content.Level; + _activatedByProximity = content.ActivatedByProximity; + _nextProximityTime = content.NextProximityTime; + _maxLevel = content.MaxLevel; + _activatedByValor = content.ActivatedByValor; + _damageEntries = content.DamageEntries; + _confinedRoaming = content.ConfinedRoaming; + _idol = content.Idol; + _hasBeenAdvanced = content.HasBeenAdvanced; + _spawnArea = content.SpawnArea; + _randomizeType = content.RandomizeType; + _kills = content.Kills; + _active = content.Active; + _type = content.Type; + _creatures = content.Creatures; + _redSkulls = content.RedSkulls; + _whiteSkulls = content.WhiteSkulls; + _platform = content.Platform; + _altar = content.Altar; + _expireDelay = content.ExpireDelay; + _expireTime = content.ExpireTime; + _champion = content.Champion; + _restartDelay = content.RestartDelay; + _restartTime = content.RestartTime; + } + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn)); [SerializableField(1)] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _activatedByProximity; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextProximityTime; @@ -96,7 +124,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _expireDelay; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(20)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expireTime; @@ -109,7 +137,7 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _restartDelay; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(23, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _restartTime; @@ -203,47 +231,38 @@ public partial class ChampionSpawn : Item } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public int MaxLevel + [SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private int _maxLevel; + + private bool AllowMaxLevelChange(ref int value) { - get => _maxLevel; - set => _maxLevel = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } - [SerializableProperty(9)] - [CommandProperty(AccessLevel.GameMaster)] - public Rectangle2D SpawnArea + [SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private Rectangle2D _spawnArea; + + private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue) { - get => _spawnArea; - set - { - _spawnArea = value; - this.MarkDirty(); - InvalidateProperties(); - UpdateRegion(); - } + UpdateRegion(); } - [SerializableProperty(11)] - [CommandProperty(AccessLevel.GameMaster)] - public int Kills + [SerializableField(11, fieldChanged: nameof(OnKillsChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _kills; + + private void OnKillsChanged(int oldValue, int newValue) { - get => _kills; - set + var n = _kills / (double)MaxKills; + var p = (int)(n * 100); + if (p < 90) { - _kills = value; - this.MarkDirty(); - - var n = _kills / (double)MaxKills; - var p = (int)(n * 100); - - if (p < 90) - { - SetWhiteSkullCount(p / 20); - } - - InvalidateProperties(); + SetWhiteSkullCount(p / 20); } } diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs index bfd00c0bf..644e2b965 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleContext.cs @@ -51,9 +51,9 @@ public partial class ChampionTitleContext } [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeAbyss))] private ChampionTitle _abyss; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAbyss() => _abyss != null; [CommandProperty(AccessLevel.GameMaster)] @@ -71,9 +71,9 @@ public partial class ChampionTitleContext } [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeArachnid))] private ChampionTitle _arachnid; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeArachnid() => _arachnid != null; [CommandProperty(AccessLevel.GameMaster)] @@ -91,9 +91,9 @@ public partial class ChampionTitleContext } [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeColdBlood))] private ChampionTitle _coldBlood; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeColdBlood() => _coldBlood != null; [CommandProperty(AccessLevel.GameMaster)] @@ -111,9 +111,9 @@ public partial class ChampionTitleContext } [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeForestLord))] private ChampionTitle _forestLord; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeForestLord() => _forestLord != null; [CommandProperty(AccessLevel.GameMaster)] @@ -131,9 +131,9 @@ public partial class ChampionTitleContext } [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeVerminHorde))] private ChampionTitle _verminHorde; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeVerminHorde() => _verminHorde != null; [CommandProperty(AccessLevel.GameMaster)] @@ -151,9 +151,9 @@ public partial class ChampionTitleContext } [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeUnholyTerror))] private ChampionTitle _unholyTerror; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeUnholyTerror() => _unholyTerror != null; [CommandProperty(AccessLevel.GameMaster)] @@ -171,9 +171,9 @@ public partial class ChampionTitleContext } [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeSleepingDragon))] private ChampionTitle _sleepingDragon; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null; [CommandProperty(AccessLevel.GameMaster)] @@ -191,9 +191,9 @@ public partial class ChampionTitleContext } [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeCorrupt))] private ChampionTitle _corrupt; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeCorrupt() => _corrupt != null; [CommandProperty(AccessLevel.GameMaster)] @@ -211,9 +211,9 @@ public partial class ChampionTitleContext } [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeGlade))] private ChampionTitle _glade; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeGlade() => _glade != null; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs index d82bfaf15..6160c4595 100644 --- a/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionTitleSystem.cs @@ -167,20 +167,13 @@ public class ChampionTitleSystem : GenericPersistence return; } - using var queue = PooledRefQueue.Create(); - foreach (var context in _championTitleContexts.Values) { if (!context.CheckAtrophy()) { - queue.Enqueue(context.Player); + _championTitleContexts.Remove(context.Player); } } - - while (queue.Count > 0) - { - _championTitleContexts.Remove((PlayerMobile)queue.Dequeue()); - } } } } diff --git a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs index a5426e7a5..88acd587e 100644 --- a/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs +++ b/Projects/UOContent/Engines/CannedEvil/StarRoomGate.cs @@ -3,15 +3,21 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class StarRoomGate : Moongate { + private void MigrateFrom(V1Content content) + { + _decays = content.Decays; + _decayTime = content.DecayTime; + } + private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0); [SerializableField(0)] private bool _decays; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _decayTime; diff --git a/Projects/UOContent/Engines/Chat/Channel.cs b/Projects/UOContent/Engines/Chat/Channel.cs index 09b8f6cb3..69f33ddcf 100644 --- a/Projects/UOContent/Engines/Chat/Channel.cs +++ b/Projects/UOContent/Engines/Chat/Channel.cs @@ -146,15 +146,8 @@ namespace Server.Engines.Chat m_Users.Remove(user); user.CurrentChannel = null; - if (m_Moderators.Contains(user)) - { - m_Moderators.Remove(user); - } - - if (m_Voices.Contains(user)) - { - m_Voices.Remove(user); - } + m_Moderators.Remove(user); + m_Voices.Remove(user); SendCommand(ChatCommand.RemoveUserFromChannel, user, user.Username); ChatSystem.SendCommandTo(user.Mobile, ChatCommand.LeaveChannel); @@ -183,10 +176,7 @@ namespace Server.Engines.Chat public void RemoveBan(ChatUser user) { - if (m_Banned.Contains(user)) - { - m_Banned.Remove(user); - } + m_Banned.Remove(user); } public void Kick(ChatUser user, ChatUser moderator = null) diff --git a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs index 29e0b9949..6ab4fffea 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/BombingRun.cs @@ -716,10 +716,7 @@ public partial class BRBomb : Item m.Target = new BombTarget(this, m); - if (m_Helpers.Contains(m)) - { - m_Helpers.Remove(m); - } + m_Helpers.Remove(m); if (m_Helpers.Count > 0) { @@ -833,10 +830,9 @@ public partial class BRBomb : Item [SerializationGenerator(0, false)] public partial class BRGoal : BaseAddon { - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnNorthChanged))] private bool _north; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnNorthChanged(bool oldValue, bool newValue) => Remake(); diff --git a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs index 6f73a0949..6aec6f328 100644 --- a/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs +++ b/Projects/UOContent/Engines/ConPVP/Games/KingOfTheHill.cs @@ -252,10 +252,9 @@ public partial class HillOfTheKing : Item public partial class KHBoard : Item { [SerializedCommandProperty(AccessLevel.GameMaster)] - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnControllerChanged))] private KHController _controller; - [SerializableFieldChanged(0)] private void OnControllerChanged(KHController oldValue, KHController newValue) { oldValue?.RemoveBoard(this); diff --git a/Projects/UOContent/Engines/ConPVP/Ruleset.cs b/Projects/UOContent/Engines/ConPVP/Ruleset.cs index fb4a1ec8b..895496c43 100644 --- a/Projects/UOContent/Engines/ConPVP/Ruleset.cs +++ b/Projects/UOContent/Engines/ConPVP/Ruleset.cs @@ -56,12 +56,11 @@ namespace Server.Engines.ConPVP public void RemoveFlavor(Ruleset flavor) { - if (!Flavors.Contains(flavor)) + if (!Flavors.Remove(flavor)) { return; } - Flavors.Remove(flavor); Options.And(flavor.Options.Not()); flavor.Options.Not(); } diff --git a/Projects/UOContent/Engines/ConPVP/Trophy.cs b/Projects/UOContent/Engines/ConPVP/Trophy.cs index fa712de33..603b52ef3 100644 --- a/Projects/UOContent/Engines/ConPVP/Trophy.cs +++ b/Projects/UOContent/Engines/ConPVP/Trophy.cs @@ -64,17 +64,13 @@ public partial class Trophy : Item UpdateStyle(); } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public TrophyRank Rank + [SerializableField(1, fieldChanged: nameof(OnRankChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private TrophyRank _rank; + + private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue) { - get => _rank; - set - { - _rank = value; - UpdateStyle(); - this.MarkDirty(); - } + UpdateStyle(); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs index 5a31aec3c..e99760bbc 100644 --- a/Projects/UOContent/Engines/Craft/Core/CraftItem.cs +++ b/Projects/UOContent/Engines/Craft/Core/CraftItem.cs @@ -1451,6 +1451,9 @@ namespace Server.Engines.Craft if (item != null) { + // Stamped here, not in OnCraft: most craftables do not implement ICraftable. + item.PlayerConstructed = true; + if (item is ICraftable craftable) { endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); @@ -1742,6 +1745,9 @@ namespace Server.Engines.Craft if (item != null) { + // Stamped here, not in OnCraft: most craftables do not implement ICraftable. + item.PlayerConstructed = true; + if (item is ICraftable craftable) { endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); diff --git a/Projects/UOContent/Engines/Ethics/Core/Player.cs b/Projects/UOContent/Engines/Ethics/Core/Player.cs index f21fe9eb9..d8aad9eb3 100644 --- a/Projects/UOContent/Engines/Ethics/Core/Player.cs +++ b/Projects/UOContent/Engines/Ethics/Core/Player.cs @@ -5,9 +5,20 @@ using Server.Mobiles; namespace Server.Ethics; [PropertyObject] -[SerializationGenerator(1)] +[SerializationGenerator(2)] public partial class Player : EthicsEntity { + private void MigrateFrom(V1Content content) + { + _mobile = content.Mobile; + _power = content.Power; + _history = content.History; + _steed = content.Steed; + _familiar = content.Familiar; + _shield = content.Shield; + _ethic = content.Ethic; + } + [SerializableField(0, setter: "private")] private Mobile _mobile; @@ -27,7 +38,7 @@ public partial class Player : EthicsEntity [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private Mobile _familiar; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(5, setter: "private")] private DateTime _shield; diff --git a/Projects/UOContent/Engines/Factions/Core/Faction.cs b/Projects/UOContent/Engines/Factions/Core/Faction.cs index 8aa8085ca..6c440584b 100644 --- a/Projects/UOContent/Engines/Factions/Core/Faction.cs +++ b/Projects/UOContent/Engines/Factions/Core/Faction.cs @@ -16,7 +16,7 @@ using Server.Targeting; namespace Server.Factions; [CustomEnum(["Minax", "Council of Mages", "True Britannians", "Shadowlords"])] -public abstract class Faction : IComparable +public abstract class Faction : IComparable, ISpanParsable { public const int StabilityFactor = 300; // 300% greater (3 times) than smallest faction public const int StabilityActivation = 200; // Stability code goes into effect when largest faction has > 200 people @@ -242,7 +242,11 @@ public abstract class Faction : IComparable public virtual void AddMember(Mobile mob) { - Members.Insert(ZeroRankOffset, new PlayerState(mob, this, Members)); + var state = new PlayerState(mob, this, Members); + Members.Insert(ZeroRankOffset, state); + + // Ranked after the insert: the ctor ran while Owner was still short a member. + state.UpdateRank(); mob.AddToBackpack(FactionItem.Imbue(new Robe(), this, false, Definition.HuePrimary)); mob.SendLocalizedMessage(1010374); // You have been granted a robe which signifies your faction @@ -1313,7 +1317,27 @@ public abstract class Faction : IComparable return null; } - public static Faction Parse(string name) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s) => Parse(s, null); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(string s, IFormatProvider provider, out Faction result) => + TryParse(s.AsSpan(), provider, out result); + + public static Faction Parse(ReadOnlySpan s, IFormatProvider provider) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + + throw new FormatException($"The input string '{s}' was not in a correct format."); + } + + public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Faction result) { var factions = Factions; @@ -1321,13 +1345,15 @@ public abstract class Faction : IComparable { var faction = factions[i]; - if (faction.Definition.FriendlyName.InsensitiveEquals(name)) + if (s.InsensitiveEquals(faction.Definition.FriendlyName)) { - return faction; + result = faction; + return true; } } - return null; + result = null; + return false; } public static bool InSkillLoss(Mobile mob) => m_SkillLoss.ContainsKey(mob); diff --git a/Projects/UOContent/Engines/Factions/Core/FactionState.cs b/Projects/UOContent/Engines/Factions/Core/FactionState.cs index 0170c718c..b8eba5091 100644 --- a/Projects/UOContent/Engines/Factions/Core/FactionState.cs +++ b/Projects/UOContent/Engines/Factions/Core/FactionState.cs @@ -114,6 +114,13 @@ public class FactionState } } + // The loop above only assigns RankIndex to members with kill points, and nothing + // computes rank on read, so rank everyone now that the ordering has settled. + foreach (var player in Members) + { + player.UpdateRank(); + } + FactionItems = []; if (version >= 2) diff --git a/Projects/UOContent/Engines/Factions/Core/PlayerState.cs b/Projects/UOContent/Engines/Factions/Core/PlayerState.cs index 792d2b4e3..e420836ce 100644 --- a/Projects/UOContent/Engines/Factions/Core/PlayerState.cs +++ b/Projects/UOContent/Engines/Factions/Core/PlayerState.cs @@ -8,7 +8,6 @@ public class PlayerState : IComparable { private Town m_Finance; - private bool m_InvalidateRank = true; private int m_KillPoints; private MerchantTitle m_MerchantTitle; private RankDefinition m_Rank; @@ -22,6 +21,10 @@ public class PlayerState : IComparable Faction = faction; Owner = owner; + // Owner does not contain this state yet, so the count is short by one; the caller ranks it + // after inserting. + SeedLowestRank(); + Attach(); Invalidate(); } @@ -54,6 +57,9 @@ public class PlayerState : IComparable } } + // Members are still being read; FactionState ranks everyone once the ordering settles. + SeedLowestRank(); + Attach(); } @@ -116,6 +122,8 @@ public class PlayerState : IComparable Owner.Remove(this); Owner.Insert(Faction.ZeroRankOffset, this); + // Direct, not through RankIndex: ZeroRankOffset is mid-update. The + // UpdateRank() at the end of this setter covers it. m_RankIndex = Faction.ZeroRankOffset; Faction.ZeroRankOffset++; } @@ -180,6 +188,7 @@ public class PlayerState : IComparable } m_KillPoints = value; + UpdateRank(); Invalidate(); } } @@ -193,49 +202,72 @@ public class PlayerState : IComparable if (m_RankIndex != value) { m_RankIndex = value; - m_InvalidateRank = true; + + UpdateRank(); + Invalidate(); } } } - public RankDefinition Rank + /// + /// Read from PlayerMobile.GetProperties, so it must stay a plain field read -- recomputing or + /// invalidating here re-enters the property list build. Maintained by . + /// + public RankDefinition Rank => m_Rank; + + // Lowest rank (Required 0): correct for an unranked member, and never null, so Rank.Title + // cannot NRE before the first UpdateRank(). + private void SeedLowestRank() { - get + var ranks = Faction.Definition.Ranks; + + if (ranks.Length > 0) { - if (m_InvalidateRank) + m_Rank = ranks[^1]; + } + } + + /// + /// Recomputes the cached rank. Call whenever , the faction's + /// ZeroRankOffset, or the member count changes -- and only once they have settled. + /// + public void UpdateRank() + { + var ranks = Faction.Definition.Ranks; + + if (ranks.Length == 0) + { + return; + } + + int percent; + + if (Owner.Count == 1) + { + percent = 1000; + } + else if (m_RankIndex == -1 || Faction.ZeroRankOffset <= 0) + { + percent = 0; + } + else + { + percent = (Faction.ZeroRankOffset - m_RankIndex) * 1000 / Faction.ZeroRankOffset; + } + + // Ranks run Required-descending ending at 0, so anything >= 0 matches below. A negative + // percent (RankIndex out of sync with ZeroRankOffset) would otherwise leave it null. + m_Rank = ranks[^1]; + + for (var i = 0; i < ranks.Length; i++) + { + var check = ranks[i]; + + if (percent >= check.Required) { - var ranks = Faction.Definition.Ranks; - int percent; - - if (Owner.Count == 1) - { - percent = 1000; - } - else if (m_RankIndex == -1) - { - percent = 0; - } - else - { - percent = (Faction.ZeroRankOffset - m_RankIndex) * 1000 / Faction.ZeroRankOffset; - } - - for (var i = 0; i < ranks.Length; i++) - { - var check = ranks[i]; - - if (percent >= check.Required) - { - m_Rank = check; - m_InvalidateRank = false; - break; - } - } - - Invalidate(); + m_Rank = check; + break; } - - return m_Rank; } } diff --git a/Projects/UOContent/Engines/Factions/Core/Town.cs b/Projects/UOContent/Engines/Factions/Core/Town.cs index 8aa136ff6..a30dc23a6 100644 --- a/Projects/UOContent/Engines/Factions/Core/Town.cs +++ b/Projects/UOContent/Engines/Factions/Core/Town.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Server.Targeting; namespace Server.Factions; [CustomEnum(["Britain", "Magincia", "Minoc", "Moonglow", "Skara Brae", "Trinsic", "Vesper", "Yew"])] -public abstract class Town : IComparable +public abstract class Town : IComparable, ISpanParsable { public const int SilverCaptureBonus = 10000; @@ -482,7 +483,27 @@ public abstract class Town : IComparable return null; } - public static Town Parse(string name) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Town Parse(string s) => Parse(s, null); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Town Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(string s, IFormatProvider provider, out Town result) => + TryParse(s.AsSpan(), provider, out result); + + public static Town Parse(ReadOnlySpan s, IFormatProvider provider) + { + if (TryParse(s, provider, out var result)) + { + return result; + } + + throw new FormatException($"The input string '{s}' was not in a correct format."); + } + + public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Town result) { var towns = Towns; @@ -490,13 +511,15 @@ public abstract class Town : IComparable { var town = towns[i]; - if (town.Definition.FriendlyName.InsensitiveEquals(name)) + if (s.InsensitiveEquals(town.Definition.FriendlyName)) { - return town; + result = town; + return true; } } - return null; + result = null; + return false; } [Usage("GrantTownSilver ")] diff --git a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs index 0402ad9a1..e599aeeb6 100644 --- a/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs +++ b/Projects/UOContent/Engines/FeatureFlags/FeatureFlagManager.cs @@ -962,20 +962,21 @@ public static class FeatureFlagManager _ = key.ToLowerInvariant() switch { // Server project flags - "player_trading" => ServerFeatureFlags.PlayerTrading = enabled, - "pvp_combat" => ServerFeatureFlags.PvPCombat = enabled, - "bank_access" => ServerFeatureFlags.BankAccess = enabled, - "speedhack_detection" => ServerFeatureFlags.SpeedhackDetection = enabled, + "player_trading" => ServerFeatureFlags.PlayerTrading = enabled, + "pvp_combat" => ServerFeatureFlags.PvPCombat = enabled, + "bank_access" => ServerFeatureFlags.BankAccess = enabled, + "speedhack_detection" => ServerFeatureFlags.SpeedhackDetection = enabled, + "insurance" => ServerFeatureFlags.InsuranceEnabled = enabled, // UOContent flags - "vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled, - "vendor_sell" => ContentFeatureFlags.VendorSell = enabled, - "player_vendors" => ContentFeatureFlags.PlayerVendors = enabled, - "house_placement" => ContentFeatureFlags.HousePlacement = enabled, - "boat_placement" => ContentFeatureFlags.BoatPlacement = enabled, - "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled, - "passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled, - "young_player_system" => ContentFeatureFlags.YoungPlayerSystem = enabled, + "vendor_purchase" => ContentFeatureFlags.VendorPurchase = enabled, + "vendor_sell" => ContentFeatureFlags.VendorSell = enabled, + "player_vendors" => ContentFeatureFlags.PlayerVendors = enabled, + "house_placement" => ContentFeatureFlags.HousePlacement = enabled, + "boat_placement" => ContentFeatureFlags.BoatPlacement = enabled, + "bulk_orders" => ContentFeatureFlags.BulkOrders = enabled, + "passive_detect_hidden" => ContentFeatureFlags.PassiveDetectHidden = enabled, + "young_player_system" => ContentFeatureFlags.YoungPlayerSystem = enabled, "bitmap_pathfinding_cache" => ContentFeatureFlags.BitmapPathfindingCache = enabled, }; } diff --git a/Projects/UOContent/Engines/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs b/Projects/UOContent/Engines/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs new file mode 100644 index 000000000..e5cad5e68 --- /dev/null +++ b/Projects/UOContent/Engines/Insurance/Gumps/CancelRenewInventoryInsuranceGump.cs @@ -0,0 +1,55 @@ +using Server.Mobiles; +using Server.Network; + +namespace Server.Gumps; + +public class CancelRenewInventoryInsuranceGump : StaticGump +{ + private readonly ItemInsuranceMenuGump _insuranceGump; + + public override bool Singleton => true; + + public CancelRenewInventoryInsuranceGump(ItemInsuranceMenuGump insuranceGump) : base(250, 200) => + _insuranceGump = insuranceGump; + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + builder.AddBackground(0, 0, 240, 142, 0x13BE); + builder.AddImageTiled(6, 6, 228, 100, 0xA40); + builder.AddImageTiled(6, 116, 228, 20, 0xA40); + builder.AddAlphaRegion(6, 6, 228, 142); + + // You are about to disable inventory insurance auto-renewal. + builder.AddHtmlLocalized(8, 8, 228, 100, 1071021, 0x7FFF); + + builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); + builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL + + builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); + builder.AddHtmlLocalized(148, 118, 450, 20, 1071022, 0x7FFF); // DISABLE IT! + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) + { + return; + } + + if (info.ButtonID == 1) + { + // You have cancelled automatically reinsuring all insured items upon death + pm.SendLocalizedMessage(1061075, "", 0x23); + pm.AutoRenewInsurance = false; + } + else + { + pm.SendLocalizedMessage(1042021); // Cancelled. + } + + if (_insuranceGump != null) + { + pm.SendGump(_insuranceGump); + } + } +} diff --git a/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs new file mode 100644 index 000000000..4fd597cf6 --- /dev/null +++ b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuConfirmGump.cs @@ -0,0 +1,46 @@ +using Server.Mobiles; +using Server.Network; + +namespace Server.Gumps; + +public class ItemInsuranceMenuConfirmGump : StaticGump +{ + private readonly ItemInsuranceMenuGump _insuranceGump; + + public ItemInsuranceMenuConfirmGump(ItemInsuranceMenuGump insuranceGump) : base(250, 200) => + _insuranceGump = insuranceGump; + + protected override void BuildLayout(ref StaticGumpBuilder builder) + { + builder.AddBackground(0, 0, 240, 142, 0x13BE); + builder.AddImageTiled(6, 6, 228, 100, 0xA40); + builder.AddImageTiled(6, 116, 228, 20, 0xA40); + builder.AddAlphaRegion(6, 6, 228, 142); + + builder.AddHtmlLocalized(8, 8, 228, 100, 1114300, 0x7FFF); // Do you wish to insure all newly selected items? + + builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); + builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL + + builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); + builder.AddHtmlLocalized(148, 118, 450, 20, 1073996, 0x7FFF); // ACCEPT + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) + { + return; + } + + if (info.ButtonID == 1) + { + _insuranceGump.ToggleSelected(); + } + else + { + pm.SendLocalizedMessage(1042021); // Cancelled. + pm.SendGump(_insuranceGump); + } + } +} diff --git a/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs new file mode 100644 index 000000000..2d17f63ab --- /dev/null +++ b/Projects/UOContent/Engines/Insurance/Gumps/ItemInsuranceMenuGump.cs @@ -0,0 +1,207 @@ +using Server.Engines.Insurance; +using Server.Mobiles; +using Server.Network; + +namespace Server.Gumps; + +public class ItemInsuranceMenuGump : DynamicGump +{ + private readonly PlayerMobile _from; + private readonly bool[] _insure; + private readonly Item[] _items; + private int _page; + + public override bool Singleton => true; + + public ItemInsuranceMenuGump(PlayerMobile from, Item[] items) : base(25, 50) + { + _from = from; + _items = items; + _insure = new bool[items.Length]; + + for (var i = 0; i < items.Length; ++i) + { + _insure[i] = items[i].Insured; + } + } + + protected override void BuildLayout(ref DynamicGumpBuilder builder) + { + builder.AddPage(); + + builder.AddBackground(0, 0, 520, 510, 0x13BE); + builder.AddImageTiled(10, 10, 500, 30, 0xA40); + builder.AddImageTiled(10, 50, 500, 355, 0xA40); + builder.AddImageTiled(10, 415, 500, 80, 0xA40); + builder.AddAlphaRegion(10, 10, 500, 485); + + builder.AddButton(15, 470, 0xFB1, 0xFB2, 0); + builder.AddHtmlLocalized(50, 472, 80, 20, 1011012, 0x7FFF); // CANCEL + + if (_from.AutoRenewInsurance) + { + builder.AddButton(360, 10, 9723, 9724, 1); + } + else + { + builder.AddButton(360, 10, 9720, 9722, 1); + } + + builder.AddHtmlLocalized(395, 14, 105, 20, 1114122, 0x7FFF); // AUTO REINSURE + + builder.AddButton(395, 470, 0xFA5, 0xFA6, 2); + builder.AddHtmlLocalized(430, 472, 50, 20, 1006044, 0x7FFF); // OK + + builder.AddHtmlLocalized(10, 14, 150, 20, 1114121, 0x7FFF); //
ITEM INSURANCE MENU
+ + builder.AddHtmlLocalized(45, 54, 70, 20, 1062214, 0x7FFF); // Item + builder.AddHtmlLocalized(250, 54, 70, 20, 1061038, 0x7FFF); // Cost + builder.AddHtmlLocalized(400, 54, 70, 20, 1114311, 0x7FFF); // Insured + + var balance = Banker.GetBalance(_from); + var cost = 0; + + for (var i = 0; i < _items.Length; ++i) + { + if (_insure[i]) + { + cost += Insurance.GetInsuranceCost(_from, _items[i]); + } + } + + builder.AddHtmlLocalized(15, 420, 300, 20, 1114310, 0x7FFF); // GOLD AVAILABLE: + builder.AddLabel(215, 420, 0x481, $"{balance}"); + builder.AddHtmlLocalized(15, 435, 300, 20, 1114123, 0x7FFF); // TOTAL COST OF INSURANCE: + builder.AddLabel(215, 435, 0x481, $"{cost}"); + + if (cost != 0) + { + builder.AddHtmlLocalized(15, 450, 300, 20, 1114125, 0x7FFF); // NUMBER OF DEATHS PAYABLE: + builder.AddLabel(215, 450, 0x481, $"{balance / cost}"); + } + + for (int i = _page * 4, y = 72; i < (_page + 1) * 4 && i < _items.Length; ++i, y += 75) + { + var item = _items[i]; + var b = ItemBounds.Bounds[item.ItemID]; + + builder.AddImageTiledButton( + 40, + y, + 0x918, + 0x918, + 0, + GumpButtonType.Page, + 0, + item.ItemID, + item.Hue, + 40 - b.Width / 2 - b.X, + 30 - b.Height / 2 - b.Y + ); + builder.AddItemProperty(item.Serial); + + if (_insure[i]) + { + builder.AddButton(400, y, 9723, 9724, 100 + i); + builder.AddLabel(250, y, 0x481, $"{Insurance.GetInsuranceCost(_from, item)}"); + } + else + { + builder.AddButton(400, y, 9720, 9722, 100 + i); + builder.AddLabel(250, y, 0x66C, $"{Insurance.GetInsuranceCost(_from, item)}"); + } + } + + if (_page >= 1) + { + builder.AddButton(15, 380, 0xFAE, 0xFAF, 3); + builder.AddHtmlLocalized(50, 380, 450, 20, 1044044, 0x7FFF); // PREV PAGE + } + + if ((_page + 1) * 4 < _items.Length) + { + builder.AddButton(400, 380, 0xFA5, 0xFA7, 4); + builder.AddHtmlLocalized(435, 380, 70, 20, 1044045, 0x7FFF); // NEXT PAGE + } + } + + public override void OnResponse(NetState sender, in RelayInfo info) + { + if (info.ButtonID == 0 || !_from.CheckAlive()) + { + return; + } + + switch (info.ButtonID) + { + case 1: // Auto Reinsure + { + if (_from.AutoRenewInsurance) + { + _from.SendGump(new CancelRenewInventoryInsuranceGump(this)); + } + else + { + Insurance.AutoRenewInventoryInsurance(_from); + _from.SendGump(this); + } + + break; + } + case 2: // OK + { + _from.SendGump(new ItemInsuranceMenuConfirmGump(this)); + + break; + } + case 3: // Prev + { + if (_page >= 1) + { + _page--; + _from.SendGump(this); + } + + break; + } + case 4: // Next + { + if ((_page + 1) * 4 < _items.Length) + { + _page++; + _from.SendGump(this); + } + + break; + } + default: + { + var idx = info.ButtonID - 100; + + if (idx >= 0 && idx < _items.Length) + { + _insure[idx] = !_insure[idx]; + } + + _from.SendGump(this); + + break; + } + } + } + + public void ToggleSelected() + { + var items = _items; + var insure = _insure; + for (var i = 0; i < items.Length; ++i) + { + var item = items[i]; + + if (item.Insured != insure[i]) + { + Insurance.ToggleItemInsurance(_from, item, false); + } + } + } +} diff --git a/Projects/UOContent/Engines/Insurance/Insurance.cs b/Projects/UOContent/Engines/Insurance/Insurance.cs new file mode 100644 index 000000000..8ba3985e5 --- /dev/null +++ b/Projects/UOContent/Engines/Insurance/Insurance.cs @@ -0,0 +1,323 @@ +using System.Collections.Generic; +using Server.Collections; +using Server.Factions; +using Server.Gumps; +using Server.Items; +using Server.Mobiles; +using Server.Targeting; + +namespace Server.Engines.Insurance; + +public static class Insurance +{ + public static bool Enabled => ServerFeatureFlags.InsuranceEnabled; + private static readonly Dictionary _insuranceContexts = []; + + public static void Configure() + { + // Legacy, but not expected to be used anymore in favor of the feature flag system + ServerFeatureFlags.InsuranceEnabled = ServerConfiguration.GetSetting("insurance.enable", Core.AOS); + } + + public static int GetInsuranceCost(Mobile from, Item item) => 600; + + public static void CheckInsuranceBeforeDeath(Mobile from) + { + if (!Enabled) + { + return; + } + + var recentDamager = from.FindMostRecentDamager(false); + if (recentDamager == null) + { + return; + } + + if (recentDamager is BaseCreature creature) + { + recentDamager = creature.GetMaster(); + } + + if (recentDamager != from && recentDamager is PlayerMobile insuranceReward) + { + _insuranceContexts[from] = new InsuranceContext(insuranceReward); + } + } + + public static void CheckInsuranceOnDeath(Mobile from) + { + if (!Enabled || !_insuranceContexts.Remove(from, out var context)) + { + return; + } + + if (context.MissedAutoRenewal) + { + from.SendLocalizedMessage(1061115); // You do not have the gold to automatically reinsure all your items. + } + + if (context.InsuranceReward != null && context.InsuranceBonus > 0 && + Banker.Deposit(context.InsuranceReward, context.InsuranceBonus)) + { + // ~1_AMOUNT~ gold has been deposited into your bank box. + context.InsuranceReward.SendLocalizedMessage(1060397, $"{context.InsuranceBonus}"); + } + } + + public static bool CheckItemInsuranceOnDeath(PlayerMobile from, Item item) + { + if (!Enabled || !item.Insured) + { + return false; + } + + if (from.DuelContext?.Registered == true && from.DuelContext.Started && from.DuelPlayer?.Eliminated != true) + { + return true; + } + + if (!_insuranceContexts.TryGetValue(from, out var context)) + { + _insuranceContexts[from] = context = new InsuranceContext(); + } + + if (from.AutoRenewInsurance) + { + var cost = Insurance.GetInsuranceCost(from, item); + + if (context.InsuranceReward != null) + { + cost /= 2; + } + + if (Banker.Withdraw(from, cost)) + { + item.PaidInsurance = true; + // ~1_AMOUNT~ gold has been withdrawn from your bank box. + from.SendLocalizedMessage(1060398, $"{cost}"); + } + else + { + // TODO: Should this spam? + // from.SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance + item.PaidInsurance = false; + item.Insured = false; + context.MissedAutoRenewal = true; + } + } + else + { + item.PaidInsurance = false; + item.Insured = false; + } + + context.InsuranceBonus += 300; + return true; + } + + public static bool CanInsure(Mobile from, Item item) + { + if (!Enabled) + { + return false; + } + + if (item is Container && item is not BaseQuiver || item is BagOfSending or KeyRing or PotionKeg or Sigil) + { + return false; + } + + if (item.Stackable) + { + return false; + } + + if (item.LootType == LootType.Cursed) + { + return false; + } + + if (item.ItemID == 0x204E) // death shroud + { + return false; + } + + if (item.Layer == Layer.Mount) + { + return false; + } + + return item.LootType != LootType.Blessed && item.LootType != LootType.Newbied && item.BlessedFor != from; + } + + public static void ToggleItemInsurance(Mobile from) + { + if (!from.CheckAlive()) + { + return; + } + + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + from.SendLocalizedMessage(1060868); // Target the item you wish to toggle insurance status on to cancel + } + + public static void ToggleItemInsurance(Mobile from, object obj) + { + if (!from.CheckAlive()) + { + return; + } + + ToggleItemInsurance(from, obj as Item, true); + } + + public static void ToggleItemInsurance(Mobile from, Item item, bool target) + { + if (item?.IsChildOf(from) != true) + { + if (target) + { + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + } + + // You can only insure items that you have equipped or that are in your backpack + from.SendLocalizedMessage(1060871, "", 0x23); + } + else if (item.Insured) + { + item.Insured = false; + + from.SendLocalizedMessage(1060874, "", 0x35); // You cancel the insurance on the item + + if (target) + { + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + // Target the item you wish to toggle insurance status on to cancel + from.SendLocalizedMessage(1060868, "", 0x23); + } + } + else if (!CanInsure(from, item)) + { + if (target) + { + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + } + + from.SendLocalizedMessage(1060869, "", 0x23); // You cannot insure that + } + else + { + if (!item.PaidInsurance) + { + var cost = GetInsuranceCost(from, item); + + if (Banker.Withdraw(from, cost)) + { + // ~1_AMOUNT~ gold has been withdrawn from your bank box. + from.SendLocalizedMessage(1060398, $"{cost}"); + item.PaidInsurance = true; + } + else + { + from.SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance + return; + } + } + + item.Insured = true; + + from.SendLocalizedMessage(1060873, "", 0x23); // You have insured the item + + if (target) + { + from.BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance); + // Target the item you wish to toggle insurance status on to cancel + from.SendLocalizedMessage(1060868, "", 0x23); + } + } + } + + public static void AutoRenewInventoryInsurance(Mobile from) + { + if (!from.CheckAlive()) + { + return; + } + + // You have selected to automatically reinsure all insured items upon death + from.SendLocalizedMessage(1060881, "", 0x23); + (from as PlayerMobile)?.AutoRenewInsurance = true; + } + + public static void CancelRenewInventoryInsurance(Mobile from) + { + if (!from.CheckAlive()) + { + return; + } + + if (Core.SE) + { + from.SendGump(new CancelRenewInventoryInsuranceGump(null)); + } + else + { + // You have cancelled automatically reinsuring all insured items upon death + from.SendLocalizedMessage(1061075, "", 0x23); + (from as PlayerMobile)?.AutoRenewInsurance = false; + } + } + + public static void OpenItemInsuranceMenu(Mobile from) + { + if (!from.CheckAlive() || from.NetState == null) + { + return; + } + + using var queue = PooledRefQueue.Create(128); + + foreach (var item in from.Items) + { + if (DisplayInItemInsuranceGump(from, item)) + { + queue.Enqueue(item); + } + } + + var pack = from.Backpack; + + if (pack != null) + { + foreach (var item in pack.FindItems()) + { + if (DisplayInItemInsuranceGump(from, item)) + { + queue.Enqueue(item); + } + } + } + + if (queue.Count == 0) + { + // None of your current items meet the requirements for insurance. + from.SendLocalizedMessage(1114915, "", 0x35); + } + else if (from is PlayerMobile pm) + { + // TODO: Investigate item sorting + from.SendGump(new ItemInsuranceMenuGump(pm, queue.ToArray())); + } + } + + private static bool DisplayInItemInsuranceGump(Mobile from, Item item) => + (item.Visible || from.AccessLevel >= AccessLevel.GameMaster) && (item.Insured || CanInsure(from, item)); + + private class InsuranceContext(PlayerMobile insuranceReward = null) + { + public PlayerMobile InsuranceReward = insuranceReward; + public int InsuranceBonus { get; set; } + public bool MissedAutoRenewal { get; set; } + } +} diff --git a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs index a5872e437..53932c8f0 100644 --- a/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs +++ b/Projects/UOContent/Engines/Khaldun/PuzzleChest.cs @@ -162,10 +162,15 @@ namespace Server.Items } } - [SerializationGenerator(0)] + [SerializationGenerator(1)] public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution { - [DeltaDateTime] + private void MigrateFrom(V0Content content) + { + _when = content.When; + } + + [AnchoredDateTime] [SerializableField(0)] private DateTime _when; @@ -237,16 +242,12 @@ namespace Server.Items } } - [SerializableProperty(0)] - public PuzzleChestSolution Solution + [SerializableField(0, fieldChanged: nameof(OnSolutionChanged))] + private PuzzleChestSolution _solution; + + private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue) { - get => _solution; - set - { - _solution = value; - InitHints(); - this.MarkDirty(); - } + InitHints(); } public PuzzleChestCylinder FirstHint @@ -550,21 +551,14 @@ namespace Server.Items return; } - using var toDelete = PooledRefQueue.Create(); - foreach (var (key, value) in _guesses) { if (Core.Now - value.When > CleanupTime) { - toDelete.Enqueue(key); + _guesses.Remove(key); } } - while (toDelete.Count > 0) - { - _guesses.Remove(toDelete.Dequeue()); - } - if (_guesses.Count == 0) { _guesses = null; diff --git a/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs b/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs index 6e37f88f5..a0de2461d 100644 --- a/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs +++ b/Projects/UOContent/Engines/ML Quests/Gumps/RaceChangeGump.cs @@ -116,10 +116,9 @@ namespace Server.Engines.MLQuests.Gumps private static void CloseCurrent(NetState ns) { - if (m_Pending.TryGetValue(ns, out var state)) + if (m_Pending.Remove(ns, out var state)) { state._timeoutToken.Cancel(); - m_Pending.Remove(ns); } ns.SendCloseRaceChanger(); diff --git a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs index 5d846d595..d3e39c09f 100644 --- a/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs +++ b/Projects/UOContent/Engines/ML Quests/MLQuestPersistence.cs @@ -19,7 +19,7 @@ namespace Server.Engines.MLQuests { base.Serialize(writer); - writer.Write(2); // version + writer.Write(3); // version writer.Write(MLQuestSystem.Contexts.Count); foreach (var context in MLQuestSystem.Contexts.Values) diff --git a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs index 2838c7736..77c3350f5 100644 --- a/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs +++ b/Projects/UOContent/Engines/ML Quests/Objectives/BaseObjective.cs @@ -119,7 +119,7 @@ namespace Server.Engines.MLQuests.Objectives if (IsTimed) { writer.Write(true); - writer.WriteDeltaTime(EndTime); + writer.WriteAnchoredTime(EndTime); } else { @@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives { if (reader.ReadBool()) { - var endTime = reader.ReadDeltaTime(); + var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (objInstance != null) { diff --git a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs index a1b368cb0..b4c5eba7c 100644 --- a/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs +++ b/Projects/UOContent/Engines/Pathing/Cache/StepCache.cs @@ -285,7 +285,8 @@ public sealed class StepCache } _lazyReaders[mapId] = reader; - logger.Information( + // Debug: opening is the expected case. A rebuild is the interesting one, and BakeMap logs it. + logger.Debug( "StepCache: opened {Path} ({ChunkCount} chunks indexed) for map {MapId}", path, reader.IndexedChunkCount, mapId ); @@ -726,20 +727,14 @@ public sealed class StepCache var window = MissPromotionWindowMs; var beforeCount = _chunkMissTracker.Count; - using var toRemove = PooledRefQueue.Create(); foreach (var kvp in _chunkMissTracker) { if (now - kvp.Value.LastMissTickStamp > window) { - toRemove.Enqueue(kvp.Key); + _chunkMissTracker.Remove(kvp.Key); } } - while (toRemove.Count > 0) - { - _chunkMissTracker.Remove(toRemove.Dequeue()); - } - if (_chunkMissTracker.Count == beforeCount) { _chunkMissTracker.Clear(); diff --git a/Projects/UOContent/Engines/Pathing/MovementPath.cs b/Projects/UOContent/Engines/Pathing/MovementPath.cs index b5307ad3d..d1ef874fc 100644 --- a/Projects/UOContent/Engines/Pathing/MovementPath.cs +++ b/Projects/UOContent/Engines/Pathing/MovementPath.cs @@ -1,7 +1,5 @@ using System; using System.Diagnostics; -using Server.Engines.Pathing; -using Server.Engines.Pathing.Cache; using Server.Items; using Server.PathAlgorithms; using Server.Spells; @@ -60,8 +58,6 @@ public sealed class MovementPath public static void Configure() { CommandSystem.Register("Path", AccessLevel.GameMaster, Path_OnCommand); - CacheEvictionTimer.Configure(); - PathCacheCommands.Configure(); } [Usage("Path")] diff --git a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs index f14886564..7f8fee270 100644 --- a/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs +++ b/Projects/UOContent/Engines/Pathing/PathCacheCommands.cs @@ -39,15 +39,12 @@ public static class PathCacheCommands 8192 ); - PathfindRecorder.Configure(); - CommandSystem.Register("PathCacheStats", AccessLevel.Administrator, OnPathCacheStats); CommandSystem.Register("PathCacheClear", AccessLevel.Administrator, OnPathCacheClear); CommandSystem.Register("PathBake", AccessLevel.Administrator, OnPathBake); CommandSystem.Register("PathCacheSave", AccessLevel.Administrator, OnPathCacheSave); CommandSystem.Register("PathCacheLoad", AccessLevel.Administrator, OnPathCacheLoad); CommandSystem.Register("PathRecord", AccessLevel.Administrator, OnPathRecord); - AutoLoadAtStartup(); } /// @@ -80,18 +77,23 @@ public static class PathCacheCommands } /// - /// Bakes any map whose .swb is missing or stale, when is - /// set. Runs in the Initialize phase, once the tile matrix and world are loaded. An up-to-date - /// cache makes it a no-op, so the cost lands only on a first boot or after a client or map - /// update moves the fingerprint. + /// Opens the existing .swb files, then — when is set — + /// bakes any that are missing or stale. An up-to-date cache makes the bake a no-op, so the cost + /// lands only on a first boot or after a client or map update moves the fingerprint. /// - /// A map is judged up-to-date by whether it has an open reader. - /// already ran in the earlier Configure phase and only opens a reader for a .swb whose - /// fingerprint validates, so an open reader is proof of a good bake — no need to fingerprint - /// the map a second time here. + /// Both halves run here rather than in Configure: the fingerprint hashes the map files, so + /// opening a .swb forces the lazy property. In Configure that would + /// build every TileMatrix ahead of TileMatrixLoader, possibly before + /// TileMatrix.Configure() settles Pre6000ClientSupport — both sit at the default + /// call priority and the phase sort is unstable. + /// + /// A reader only opens once its fingerprint validates, so an open reader is proof of a good + /// bake and the map is skipped without fingerprinting it again. /// public static void Initialize() { + AutoLoadAtStartup(); + if (!ServerConfiguration.GetSetting(PrebakeSetting, false)) { return; @@ -119,13 +121,15 @@ public static class PathCacheCommands ); StepCache.Instance.BakeMap(map.MapID, path); StepCache.Instance.ClearResidentChunks(); + + // Just this map: a blanket AutoLoadAtStartup() would reopen every reader already open. + StepCache.Instance.TryOpenLazyReader(path, map.MapID); baked++; } if (baked > 0) { logger.Information("PathBake: pre-bake complete ({Count} map(s) written).", baked); - AutoLoadAtStartup(); // reopen what we just wrote } } diff --git a/Projects/UOContent/Engines/Plants/PlantItem.cs b/Projects/UOContent/Engines/Plants/PlantItem.cs index 60451f53b..5f66b1a7a 100644 --- a/Projects/UOContent/Engines/Plants/PlantItem.cs +++ b/Projects/UOContent/Engines/Plants/PlantItem.cs @@ -37,17 +37,17 @@ public partial class PlantItem : Item, ISecurable [SerializedIgnoreDupe] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeSecureLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SecureLevel _level; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeSecureLevel() => (int)_level != 0; [SerializedIgnoreDupe] [SerializableField(5, setter: "private")] + [SaveFlag(nameof(ShouldSerializePlantSystem))] private PlantSystem _plantSystem; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant; // For clients older than 7.0.12.0 @@ -82,6 +82,7 @@ public partial class PlantItem : Item, ISecurable [CommandProperty(AccessLevel.GameMaster)] [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializePlantStatus))] public PlantStatus PlantStatus { get => _plantStatus; @@ -120,53 +121,38 @@ public partial class PlantItem : Item, ISecurable } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public PlantType PlantType + [SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))] + [SaveFlag(nameof(ShouldSerializePlantType))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PlantType _plantType; + + private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue) { - get => _plantType; - set - { - _plantType = value; - Update(); - } + Update(); } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializePlantType() => (int)_plantType != 0; - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public PlantHue PlantHue + [SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))] + [SaveFlag(nameof(ShouldSerializePlantHue))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PlantHue _plantHue; + + private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) { - get => _plantHue; - set - { - _plantHue = value; - Update(); - } + Update(); } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None; - [SerializableProperty(4)] - [CommandProperty(AccessLevel.GameMaster)] - public bool ShowType - { - get => _showType; - set - { - _showType = value; - InvalidateProperties(); - this.MarkDirty(); - } - } + [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeShowType))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _showType; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeShowType() => _showType; [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Engines/Plants/PlantSystem.cs b/Projects/UOContent/Engines/Plants/PlantSystem.cs index 29b9f48be..4b202ef63 100644 --- a/Projects/UOContent/Engines/Plants/PlantSystem.cs +++ b/Projects/UOContent/Engines/Plants/PlantSystem.cs @@ -33,24 +33,24 @@ namespace Server.Engines.Plants private PlantItem _plant; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeFertileDirt))] private bool _fertileDirt; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeFertileDirt() => _fertileDirt; [SerializableField(1)] private DateTime _nextGrowth; [SerializableField(2, setter: "private")] + [SaveFlag(nameof(ShouldSerializeGrowthIndicator))] private PlantGrowthIndicator _growthIndicator; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeGrowthIndicator() => _growthIndicator != PlantGrowthIndicator.None; [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializePollinated))] private bool _pollinated; - [SerializableFieldSaveFlag(13)] private bool ShouldSerializePollinated() => _pollinated; public PlantSystem(PlantItem plant) @@ -97,45 +97,42 @@ namespace Server.Engines.Plants public bool IsFullWater => _water >= 4; - [SerializableProperty(3)] - public int Water + [SerializableField(3, fieldChanged: nameof(OnWaterChanged), allowFieldChange: nameof(AllowWaterChange))] + [SaveFlag(nameof(ShouldSerializeWater))] + private int _water; + + private bool AllowWaterChange(ref int value) { - get => _water; - set - { - _water = Math.Clamp(value, 0, 4); - Plant.InvalidateProperties(); - MarkDirty(); - } + value = Math.Clamp(value, 0, 4); + return true; + } + + private void OnWaterChanged(int oldValue, int newValue) + { + Plant.InvalidateProperties(); } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWater() => _water != 0; - [SerializableProperty(4)] - public int Hits + [SerializableField(4, fieldChanged: nameof(OnHitsChanged), allowFieldChange: nameof(AllowHitsChange))] + [SaveFlag(nameof(ShouldSerializeHits))] + private int _hits; + + private bool AllowHitsChange(ref int value) { - get => _hits; - set - { - if (_hits == value) - { - return; - } - - _hits = Math.Clamp(value, 0, MaxHits); - - if (_hits == 0) - { - Plant.Die(); - } - - Plant.InvalidateProperties(); - MarkDirty(); - } + value = Math.Clamp(value, 0, MaxHits); + return true; + } + + private void OnHitsChanged(int oldValue, int newValue) + { + if (_hits == 0) + { + Plant.Die(); + } + Plant.InvalidateProperties(); } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHits() => _hits != 0; public int MaxHits => 10 + (int)Plant.PlantStatus * 2; @@ -149,124 +146,108 @@ namespace Server.Engines.Plants _ => PlantHealth.Vibrant }; - [SerializableProperty(5)] - public int Infestation + [SerializableField(5, allowFieldChange: nameof(AllowInfestationChange))] + [SaveFlag(nameof(ShouldSerializeInfestation))] + private int _infestation; + + private bool AllowInfestationChange(ref int value) { - get => _infestation; - set - { - _infestation = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeInfestation() => _infestation != 0; - [SerializableProperty(6)] - public int Fungus + [SerializableField(6, allowFieldChange: nameof(AllowFungusChange))] + [SaveFlag(nameof(ShouldSerializeFungus))] + private int _fungus; + + private bool AllowFungusChange(ref int value) { - get => _fungus; - set - { - _fungus = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeFungus() => _fungus != 0; - [SerializableProperty(7)] - public int Poison + [SerializableField(7, allowFieldChange: nameof(AllowPoisonChange))] + [SaveFlag(nameof(ShouldSerializePoison))] + private int _poison; + + private bool AllowPoisonChange(ref int value) { - get => _poison; - set - { - _poison = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(7)] private bool ShouldSerializePoison() => _poison != 0; - [SerializableProperty(8)] - public int Disease + [SerializableField(8, allowFieldChange: nameof(AllowDiseaseChange))] + [SaveFlag(nameof(ShouldSerializeDisease))] + private int _disease; + + private bool AllowDiseaseChange(ref int value) { - get => _disease; - set - { - _disease = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeDisease() => _disease != 0; public bool IsFullPoisonPotion => _poisonPotion >= 2; - [SerializableProperty(9)] - public int PoisonPotion + [SerializableField(9, allowFieldChange: nameof(AllowPoisonPotionChange))] + [SaveFlag(nameof(ShouldSerializePoisonPotion))] + private int _poisonPotion; + + private bool AllowPoisonPotionChange(ref int value) { - get => _poisonPotion; - set - { - _poisonPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializePoisonPotion() => _poisonPotion != 0; public bool IsFullCurePotion => _curePotion >= 2; - [SerializableProperty(10)] - public int CurePotion + [SerializableField(10, allowFieldChange: nameof(AllowCurePotionChange))] + [SaveFlag(nameof(ShouldSerializeCurePotion))] + private int _curePotion; + + private bool AllowCurePotionChange(ref int value) { - get => _curePotion; - set - { - _curePotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCurePotion() => _curePotion != 0; public bool IsFullHealPotion => _healPotion >= 2; - [SerializableProperty(11)] - public int HealPotion + [SerializableField(11, allowFieldChange: nameof(AllowHealPotionChange))] + [SaveFlag(nameof(ShouldSerializeHealPotion))] + private int _healPotion; + + private bool AllowHealPotionChange(ref int value) { - get => _healPotion; - set - { - _healPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeHealPotion() => _healPotion != 0; public bool IsFullStrengthPotion => _strengthPotion >= 2; - [SerializableProperty(12)] - public int StrengthPotion + [SerializableField(12, allowFieldChange: nameof(AllowStrengthPotionChange))] + [SaveFlag(nameof(ShouldSerializeStrengthPotion))] + private int _strengthPotion; + + private bool AllowStrengthPotionChange(ref int value) { - get => _strengthPotion; - set - { - _strengthPotion = Math.Clamp(value, 0, 2); - MarkDirty(); - } + value = Math.Clamp(value, 0, 2); + return true; } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeStrengthPotion() => _strengthPotion != 0; public bool HasMaladies => Infestation > 0 || Fungus > 0 || Poison > 0 || Disease > 0 || Water != 2; @@ -274,6 +255,7 @@ namespace Server.Engines.Plants public bool PollenProducing => Plant.IsCrossable && Plant.PlantStatus >= PlantStatus.FullGrownPlant; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeSeedType))] public PlantType SeedType { get => Pollinated ? _seedType : Plant.PlantType; @@ -284,10 +266,10 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeSeedType() => _pollinated; [SerializableProperty(15)] + [SaveFlag(nameof(ShouldSerializeSeedHue))] public PlantHue SeedHue { get => Pollinated ? _seedHue : Plant.PlantHue; @@ -298,53 +280,58 @@ namespace Server.Engines.Plants } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeSeedHue() => _pollinated; - [SerializableProperty(16)] - public int AvailableSeeds + [SerializableField(16, allowFieldChange: nameof(AllowAvailableSeedsChange))] + [SaveFlag(nameof(ShouldSerializeAvailableSeeds))] + private int _availableSeeds; + + private bool AllowAvailableSeedsChange(ref int value) { - get => _availableSeeds; - set => _availableSeeds = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeAvailableSeeds() => _availableSeeds != 0; - [SerializableProperty(17)] - public int LeftSeeds + [SerializableField(17, allowFieldChange: nameof(AllowLeftSeedsChange))] + [SaveFlag(nameof(ShouldSerializeLeftSeeds), nameof(LeftSeedsDefaultValue))] + private int _leftSeeds; + + private bool AllowLeftSeedsChange(ref int value) { - get => _leftSeeds; - set => _leftSeeds = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeLeftSeeds() => _leftSeeds != 8; - [SerializableFieldDefault(17)] private int LeftSeedsDefaultValue() => 8; - [SerializableProperty(18)] - public int AvailableResources + [SerializableField(18, allowFieldChange: nameof(AllowAvailableResourcesChange))] + [SaveFlag(nameof(ShouldSerializeAvailableResources))] + private int _availableResources; + + private bool AllowAvailableResourcesChange(ref int value) { - get => _availableResources; - set => _availableResources = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeAvailableResources() => _availableResources != 0; - [SerializableProperty(19)] - public int LeftResources + [SerializableField(19, allowFieldChange: nameof(AllowLeftResourcesChange))] + [SaveFlag(nameof(ShouldSerializeLeftResources), nameof(LeftResourcesDefaultValue))] + private int _leftResources; + + private bool AllowLeftResourcesChange(ref int value) { - get => _leftResources; - set => _leftResources = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeLeftResources() => _leftResources != 8; - [SerializableFieldDefault(19)] private int LeftResourcesDefaultValue() => 8; public void Reset(bool potions) diff --git a/Projects/UOContent/Engines/Plants/Seed.cs b/Projects/UOContent/Engines/Plants/Seed.cs index 24a290f0b..ed6f13b7b 100644 --- a/Projects/UOContent/Engines/Plants/Seed.cs +++ b/Projects/UOContent/Engines/Plants/Seed.cs @@ -35,18 +35,14 @@ public partial class Seed : Item public override double DefaultWeight => 1.0; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1)] - public PlantHue PlantHue + [SerializableField(1, fieldChanged: nameof(OnPlantHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private PlantHue _plantHue; + + private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue) { - get => _plantHue; - set - { - _plantHue = value; - Hue = PlantHueInfo.GetInfo(value).Hue; - InvalidateProperties(); - this.MarkDirty(); - } + Hue = PlantHueInfo.GetInfo(newValue).Hue; } public override int LabelNumber => 1060810; // seed diff --git a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs index cecd7bf7e..384c91c98 100644 --- a/Projects/UOContent/Engines/Player Murder System/MurderContext.cs +++ b/Projects/UOContent/Engines/Player Murder System/MurderContext.cs @@ -16,12 +16,14 @@ public partial class MurderContext [SerializedCommandProperty(AccessLevel.GameMaster)] private TimeSpan _longTermElapse; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int ShortTermMurders + [SerializableField(2, allowFieldChange: nameof(AllowShortTermMurdersChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _shortTermMurders; + + private bool AllowShortTermMurdersChange(ref int value) { - get => _shortTermMurders; - set => _shortTermMurders = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } [SerializableField(3)] diff --git a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs index b5a1b77d5..d1be82aa9 100644 --- a/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs +++ b/Projects/UOContent/Engines/Player Murder System/PlayerMurderSystem.cs @@ -403,27 +403,20 @@ public class PlayerMurderSystem : GenericPersistence return; } - using var queue = PooledRefQueue.Create(); - foreach (var context in _contextTerms) { context.DecayKills(); if (!context.CheckStart()) { - queue.Enqueue(context.Player); - } - } - - while (queue.Count > 0) - { - var pm = (PlayerMobile)queue.Dequeue(); - if (_murderContexts.TryGetValue(pm, out var ctx)) - { - if (ctx.CanRemove()) + var pm = context.Player; + if (_murderContexts.TryGetValue(pm, out var ctx)) { - _murderContexts.Remove(pm); + if (ctx.CanRemove()) + { + _murderContexts.Remove(pm); + } + _contextTerms.Remove(ctx); } - _contextTerms.Remove(ctx); } } } diff --git a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs index d5ec6f24a..4e3e5e237 100644 --- a/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs +++ b/Projects/UOContent/Engines/Quests/The Summoning/Items/SummoningAltar.cs @@ -12,16 +12,12 @@ public partial class SummoningAltar : AbbatoirAddon { } - [SerializableProperty(0)] - public BoneDemon Daemon + [SerializableField(0, fieldChanged: nameof(OnDaemonChanged))] + private BoneDemon _daemon; + + private void OnDaemonChanged(BoneDemon oldValue, BoneDemon newValue) { - get => _daemon; - set - { - _daemon = value; - CheckDaemon(); - this.MarkDirty(); - } + CheckDaemon(); } public void CheckDaemon() diff --git a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs index 7ceeb00f6..8efc54b8c 100644 --- a/Projects/UOContent/Engines/Spawners/BaseSpawner.cs +++ b/Projects/UOContent/Engines/Spawners/BaseSpawner.cs @@ -54,10 +54,10 @@ public abstract partial class BaseSpawner : Item, ISpawner [SerializedCommandProperty(AccessLevel.Developer)] private Guid _guid; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeReturnOnDeactivate() => _returnOnDeactivate; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeReturnOnDeactivate))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _returnOnDeactivate; @@ -67,48 +67,46 @@ public abstract partial class BaseSpawner : Item, ISpawner private int _walkingRange = -1; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeWayPoint() => _wayPoint != null; [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeWayPoint))] [SerializedCommandProperty(AccessLevel.Developer)] private WayPoint _wayPoint; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeGroup() => _group; [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeGroup))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _group; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeMinDelay() => _minDelay != DefaultMinDelay; - [SerializableFieldDefault(6)] private TimeSpan MinDelayDefault() => DefaultMinDelay; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeMinDelay), nameof(MinDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _minDelay; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeMaxDelay() => _maxDelay != DefaultMaxDelay; - [SerializableFieldDefault(7)] private TimeSpan MaxDelayDefault() => DefaultMaxDelay; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeMaxDelay), nameof(MaxDelayDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private TimeSpan _maxDelay; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeTeam() => _team != 0; [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeTeam))] [SerializedCommandProperty(AccessLevel.Developer)] private int _team; @@ -125,29 +123,29 @@ public abstract partial class BaseSpawner : Item, ISpawner /// If true, the home location of the spawn is the location where it spawned /// If false, the home location of the spawn is the location of the spawner /// - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeSpawnLocationIsHome() => _spawnLocationIsHome; [InvalidateProperties] [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeSpawnLocationIsHome))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _spawnLocationIsHome; - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeEnd() => _end != default; [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeEnd))] [SerializedCommandProperty(AccessLevel.Developer)] private DateTime _end; /// /// Controls how spawn position optimization is handled. /// - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeSpawnPositionMode() => _spawnPositionMode is not SpawnPositionMode.Automatic and not SpawnPositionMode.Abandoned; [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeSpawnPositionMode))] [SerializedCommandProperty(AccessLevel.Developer)] private SpawnPositionMode _spawnPositionMode; @@ -156,13 +154,12 @@ public abstract partial class BaseSpawner : Item, ISpawner /// /// Maximum number of random position attempts before engaging optimization. /// - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMaxSpawnAttempts() => _maxSpawnAttempts != DefaultMaxSpawnAttempts; - [SerializableFieldDefault(14)] private int MaxSpawnAttemptsDefault() => DefaultMaxSpawnAttempts; [SerializableField(14)] + [SaveFlag(nameof(ShouldSerializeMaxSpawnAttempts), nameof(MaxSpawnAttemptsDefault))] [SerializedCommandProperty(AccessLevel.Developer)] private int _maxSpawnAttempts; @@ -314,26 +311,20 @@ public abstract partial class BaseSpawner : Item, ISpawner } } - [SerializableProperty(8)] - [CommandProperty(AccessLevel.Developer)] - public int Count + [SerializableField(8, fieldChanged: nameof(OnCountChanged))] + [SerializedCommandProperty(AccessLevel.Developer)] + [InvalidateProperties] + private int _count; + + private void OnCountChanged(int oldValue, int newValue) { - get => _count; - set + if (IsFull) { - _count = value; - - if (IsFull) - { - _timer?.Stop(); - } - else if (_timer?.Running != true) - { - DoTimer(); - } - - InvalidateProperties(); - this.MarkDirty(); + _timer?.Stop(); + } + else if (_timer?.Running != true) + { + DoTimer(); } } diff --git a/Projects/UOContent/Engines/Spawners/Spawner.cs b/Projects/UOContent/Engines/Spawners/Spawner.cs index 7c07d2681..db25cab55 100644 --- a/Projects/UOContent/Engines/Spawners/Spawner.cs +++ b/Projects/UOContent/Engines/Spawners/Spawner.cs @@ -10,17 +10,17 @@ public partial class Spawner : BaseSpawner /// When true, enables proactive spiral scanning to find valid spawn positions. /// Only relevant when SpawnPositionMode is Automatic or Enabled. /// - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeUseSpiralScan() => _useSpiralScan; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeUseSpiralScan))] [SerializedCommandProperty(AccessLevel.Developer)] private bool _useSpiralScan; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeSpawnBounds() => _spawnBounds != default; [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeSpawnBounds))] [CommandProperty(AccessLevel.Developer)] public override Rectangle3D SpawnBounds { diff --git a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs index 396b87dcd..4cd8d8c63 100644 --- a/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs +++ b/Projects/UOContent/Engines/Stealables/StealableArtifacts.cs @@ -248,7 +248,7 @@ public class StealableArtifacts : GenericPersistence public override void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(1); // version + writer.WriteEncodedInt(2); // version writer.Write(_enabled); @@ -261,7 +261,7 @@ public class StealableArtifacts : GenericPersistence var si = _artifacts[i]; writer.Write(si.Item); - writer.WriteDeltaTime(si.NextRespawn); + writer.WriteAnchoredTime(si.NextRespawn); } } } @@ -282,7 +282,7 @@ public class StealableArtifacts : GenericPersistence for (var i = 0; i < length; i++) { var item = reader.ReadEntity(); - var nextRespawn = reader.ReadDeltaTime(); + var nextRespawn = version >= 2 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (i < _artifacts.Length) { diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs index 005688e1e..c42cfae4e 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/GreaterArtifacts.cs @@ -332,27 +332,22 @@ public partial class PigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public PigmentsOfTokuno(PigmentType type, int uses) : base(uses) => Type = type; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public PigmentType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private PigmentType _type; + + private void OnTypeChanged(PigmentType oldValue, PigmentType newValue) { - get => _type; - set + var v = (int)_type; + if (v >= 0 && v < _table.Length) { - _type = value; - - var v = (int)_type; - - if (v >= 0 && v < _table.Length) - { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; - } + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; } } diff --git a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs index 54d026df4..22c6af344 100644 --- a/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs +++ b/Projects/UOContent/Engines/Treasures of Tokuno/LesserArtifacts.cs @@ -617,27 +617,22 @@ public partial class LesserPigmentsOfTokuno : BasePigmentsOfTokuno [Constructible] public LesserPigmentsOfTokuno(LesserPigmentType type) : base(1) => Type = type; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public LesserPigmentType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private LesserPigmentType _type; + + private void OnTypeChanged(LesserPigmentType oldValue, LesserPigmentType newValue) { - get => _type; - set + var v = (int)_type; + if (v >= 0 && v < _table.Length) { - _type = value; - - var v = (int)_type; - - if (v >= 0 && v < _table.Length) - { - Hue = _table[v][0]; - Label = _table[v][1]; - } - else - { - Hue = 0; - Label = -1; - } + Hue = _table[v][0]; + Label = _table[v][1]; + } + else + { + Hue = 0; + Label = -1; } } diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs index ff665ec53..7a62cc735 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatue.cs @@ -79,45 +79,33 @@ public partial class CharacterStatue : Mobile, IRewardItem InvalidateHues(); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueType StatueType + [SerializableField(0, fieldChanged: nameof(OnStatueTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueType _statueType; + + private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) { - get => _statueType; - set - { - _statueType = value; - InvalidateHues(); - InvalidatePose(); - this.MarkDirty(); - } + InvalidateHues(); + InvalidatePose(); } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public StatuePose Pose + [SerializableField(1, fieldChanged: nameof(OnPoseChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatuePose _pose; + + private void OnPoseChanged(StatuePose oldValue, StatuePose newValue) { - get => _pose; - set - { - _pose = value; - InvalidatePose(); - this.MarkDirty(); - } + InvalidatePose(); } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueMaterial Material + [SerializableField(2, fieldChanged: nameof(OnMaterialChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueMaterial _material; + + private void OnMaterialChanged(StatueMaterial oldValue, StatueMaterial newValue) { - get => _material; - set - { - _material = value; - InvalidateHues(); - InvalidatePose(); - this.MarkDirty(); - } + InvalidateHues(); + InvalidatePose(); } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs index a028b1c85..48ae30ae9 100644 --- a/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs +++ b/Projects/UOContent/Engines/Veteran Rewards/Character Statue Maker/CharacterStatueMaker.cs @@ -25,17 +25,13 @@ public partial class CharacterStatueMaker : Item, IRewardItem public override int LabelNumber => 1076173; // Character Statue Maker - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public StatueType StatueType + [SerializableField(1, fieldChanged: nameof(OnStatueTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private StatueType _statueType; + + private void OnStatueTypeChanged(StatueType oldValue, StatueType newValue) { - get => _statueType; - set - { - _statueType = value; - InvalidateHue(); - this.MarkDirty(); - } + InvalidateHue(); } public override void OnDoubleClick(Mobile from) diff --git a/Projects/UOContent/Engines/Virtues/VirtueContext.cs b/Projects/UOContent/Engines/Virtues/VirtueContext.cs index 3b26b5294..7524f77ed 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueContext.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueContext.cs @@ -5,102 +5,121 @@ using Server.Mobiles; namespace Server.Engines.Virtues; [PropertyObject] -[SerializationGenerator(0)] +[SerializationGenerator(1)] public partial class VirtueContext { - [DeltaDateTime] + private void MigrateFrom(V0Content content) + { + // Save-flagged values arrive as nullables; unset flags fall back to the same + // defaults the old deserialize left in place. + _lastSacrificeGain = content.LastSacrificeGain ?? default; + _lastSacrificeLoss = content.LastSacrificeLoss ?? default; + _availableResurrects = content.AvailableResurrects ?? 0; + _lastJusticeLoss = content.LastJusticeLoss ?? default; + _lastCompassionLoss = content.LastCompassionLoss ?? default; + _nextCompassionDay = content.NextCompassionDay ?? default; + _compassionGains = content.CompassionGains ?? 0; + _lastValorLoss = content.LastValorLoss ?? default; + _lastHonorUse = content.LastHonorUse ?? default; + _honorActive = content.HonorActive; + _justiceProtection = content.JusticeProtection; + _justiceStatus = content.JusticeStatus ?? JusticeProtectorStatus.None; + _values = content.Values; + } + + [AnchoredDateTime] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeLastSacrificeGain))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeGain; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeLastSacrificeGain() => !SacrificeVirtue.CanGain(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLastSacrificeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastSacrificeLoss; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLastSacrificeLoss() => !SacrificeVirtue.CanAtrophy(this); [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeAvailableResurrects))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _availableResurrects; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAvailableResurrects() => _availableResurrects > 0; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeLastJusticeLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastJusticeLoss; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeLastJusticeLoss() => !JusticeVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeLastCompassionLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastCompassionLoss; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeLastCompassionLoss() => !CompassionVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeNextCompassionDay))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextCompassionDay; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeNextCompassionDay() => _nextCompassionDay > Core.Now; [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeCompassionGains))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _compassionGains; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCompassionGains() => _compassionGains > 0; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeValorLoss))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private DateTime _lastValorLoss; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeValorLoss() => !ValorVirtue.CanAtrophy(this); - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeLastHonorUse))] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _lastHonorUse; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeLastHonorUse() => !HonorVirtue.CanUse(this); [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeHonorActive))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] private bool _honorActive; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHonorActive() => _honorActive; [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeJusticeProtection))] private PlayerMobile _justiceProtection; - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeJusticeProtection() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeJusticeStatus))] private JusticeProtectorStatus _justiceStatus; - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeJusticeStatus() => _justiceProtection != null && _justiceStatus != JusticeProtectorStatus.None; [SerializableField(12, setter: "private")] + [SaveFlag(nameof(ShouldSerializeValues))] private int[] _values; - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeValues() { if (_values == null) diff --git a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs index 89c5fa3b5..e57a2d386 100644 --- a/Projects/UOContent/Engines/Virtues/VirtueSystem.cs +++ b/Projects/UOContent/Engines/Virtues/VirtueSystem.cs @@ -372,8 +372,6 @@ public class VirtueSystem : GenericPersistence return; } - using var queue = PooledRefQueue.Create(); - // This is not particularly efficient. If it gets too slow, then use a different architecture. foreach (var (player, virtues) in _playerVirtues) { @@ -381,14 +379,9 @@ public class VirtueSystem : GenericPersistence if (!virtues.IsUsed()) { - queue.Enqueue(player); + _playerVirtues.Remove(player); } } - - while (queue.Count > 0) - { - _playerVirtues.Remove((PlayerMobile)queue.Dequeue()); - } } ~VirtueTimer() diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index 6e7c3ac23..a4fbe0f18 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -5,12 +5,14 @@ using System.Net; using System.Runtime.InteropServices; using System.Threading; using Server.Accounting; +using Server.Accounting.Security; using Server.Collections; using Server.Commands; using Server.Maps; using Server.Misc; using Server.Multis; using Server.Network; +using Server.Network.Bans; using Server.Prompts; using Server.Saves; using Server.Text; @@ -225,9 +227,12 @@ namespace Server.Gumps } case AdminGumpPage.Information_Perf: { - AddLabel(20, 130, LabelHue, "Cycles Per Second:"); - AddLabel(40, 150, LabelHue, $"Current: {Core.CyclesPerSecond:N2}"); - AddLabel(40, 170, LabelHue, $"Average: {Core.AverageCPS:N2}"); + var loopStatus = Core.IdleSleepUnsupported ? "Spinning - host cannot honor short waits" : + Core.EventLoopIdleWaitMs == 0 ? "Spinning (configured)" : + Core.IdleSleepSuspended ? "Sleep suspended - host returning waits late" : "Healthy"; + + AddLabel(20, 130, LabelHue, "Event Loop:"); + AddLabel(40, 150, LabelHue, loopStatus); using var sb = ValueStringBuilder.Create(); @@ -1743,7 +1748,8 @@ namespace Server.Gumps { for (var i = 0; i < a.LoginIPs.Length; ++i) { - AdminFirewall.Add(a.LoginIPs[i]); + Firewall.Add(new SingleIpFirewallEntry(a.LoginIPs[i])); + BanChannel.Report(a.LoginIPs[i], TimeSpan.Zero, BanReasons.Manual); } notice = "All addresses in the list have been firewalled."; @@ -1767,7 +1773,13 @@ namespace Server.Gumps if (okay) { - AdminFirewall.Add(toFirewall); + var firewallEntry = Firewall.ToFirewallEntry(toFirewall); + Firewall.Add(firewallEntry); + + if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress) + { + BanChannel.Report(firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, BanReasons.Manual); + } notice = $"{toFirewall} : Added to firewall."; } @@ -2895,7 +2907,7 @@ namespace Server.Gumps else { notice = "The password has been changed."; - a.SetPassword(password); + PasswordWorker.SetPassword(a, password, null); page = AdminGumpPage.AccountDetails_Information; CommandLogging.WriteLine( from, @@ -3559,7 +3571,7 @@ namespace Server.Gumps IFirewallEntry firewallEntry; try { - firewallEntry = AdminFirewall.ToFirewallEntry(text); + firewallEntry = Firewall.ToFirewallEntry(text); } catch { @@ -3581,7 +3593,17 @@ namespace Server.Gumps $"{from.AccessLevel} {CommandLogging.Format(from)} firewalling {firewallEntry}" ); - AdminFirewall.Add(firewallEntry); + Firewall.Add(firewallEntry); + + if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress) + { + BanChannel.Report( + firewallEntry.MinIpAddress.ToIpAddress(), + TimeSpan.Zero, + BanReasons.Manual + ); + } + from.SendGump( new AdminGump( from, @@ -3620,7 +3642,13 @@ namespace Server.Gumps $"{from.AccessLevel} {CommandLogging.Format(from)} removing {m_State} from firewall list" ); - AdminFirewall.Remove(m_State); + Firewall.Remove(m_State as IFirewallEntry); + + if (m_State is IFirewallEntry fe && fe.MinIpAddress == fe.MaxIpAddress) + { + BanChannel.Retract(fe.MinIpAddress.ToIpAddress()); + } + from.SendGump( new AdminGump( from, diff --git a/Projects/UOContent/Gumps/Base/BaseGump.cs b/Projects/UOContent/Gumps/Base/BaseGump.cs index 6d844735d..05770608c 100644 --- a/Projects/UOContent/Gumps/Base/BaseGump.cs +++ b/Projects/UOContent/Gumps/Base/BaseGump.cs @@ -13,6 +13,7 @@ * along with this program. If not, see . * *************************************************************************/ +using Server.Logging; using Server.Network; using System; using System.Buffers; @@ -23,10 +24,12 @@ namespace Server.Gumps; public abstract class BaseGump { private static readonly byte[] _packetBuffer = GC.AllocateUninitializedArray(0x10000); + private static readonly ILogger _logger = LogFactory.GetLogger(typeof(BaseGump)); private static Serial nextSerial = (Serial)1; public int TypeID { get; protected set; } public Serial Serial { get; protected set; } + protected bool HasVisualElements { get; set; } public abstract int Switches { get; } public abstract int TextEntries { get; } @@ -56,6 +59,11 @@ public abstract class BaseGump var writer = new SpanWriter(_packetBuffer); Compile(ref writer); + if (!HasVisualElements) + { + _logger.Warning("Sending empty gump {GumpType}", GetType().FullName); + } + ns.Send(writer.Span); writer.Dispose(); diff --git a/Projects/UOContent/Gumps/Base/DynamicGump.cs b/Projects/UOContent/Gumps/Base/DynamicGump.cs index f1b3c064a..de894da68 100644 --- a/Projects/UOContent/Gumps/Base/DynamicGump.cs +++ b/Projects/UOContent/Gumps/Base/DynamicGump.cs @@ -47,6 +47,7 @@ public abstract class DynamicGump : BaseGump BuildLayout(ref gumpBuilder); gumpBuilder.FinalizeLayout(); + HasVisualElements = gumpBuilder.HasVisualElements; _switches = gumpBuilder.Switches; _textEntries = gumpBuilder.TextEntries; diff --git a/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs b/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs index e98c8c6df..9238e6e04 100644 --- a/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs +++ b/Projects/UOContent/Gumps/Base/DynamicGumpBuilder.cs @@ -36,6 +36,7 @@ public ref struct DynamicGumpBuilder public int Switches => _gumpBuilder._switches; public int TextEntries => _gumpBuilder._textEntries; + internal bool HasVisualElements => _gumpBuilder._hasVisualElements; [MethodImpl(MethodImplOptions.AggressiveInlining)] public DynamicGumpBuilder() diff --git a/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs b/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs index 7f0ca5dff..4bd5c7d8f 100644 --- a/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs +++ b/Projects/UOContent/Gumps/Base/GumpLayoutBuilder.cs @@ -30,6 +30,7 @@ public ref struct GumpLayoutBuilder internal GumpFlags _flags; internal int _switches; internal int _textEntries; + internal bool _hasVisualElements; internal Span LayoutData => _layoutBuffer.AsSpan(0, _bytesWritten); @@ -95,6 +96,7 @@ public ref struct GumpLayoutBuilder public void AddBackground(int x, int y, int width, int height, int gumpId) { + _hasVisualElements = true; GrowIfNeeded(9 + 9 + 45); WriteStart("resizepic"u8); WriteValue(x); @@ -109,6 +111,7 @@ public ref struct GumpLayoutBuilder int x, int y, int normalId, int pressedId, int buttonId, GumpButtonType type = GumpButtonType.Reply, int param = 0 ) { + _hasVisualElements = true; GrowIfNeeded(11 + 6 + 54 + 2); WriteStart("button"u8); WriteValue(x); @@ -123,6 +126,7 @@ public ref struct GumpLayoutBuilder public void AddCheckbox(int x, int y, int inactiveId, int activeId, bool selected, int switchId) { + _hasVisualElements = true; GrowIfNeeded(10 + 8 + 45 + 2); WriteStart("checkbox"u8); WriteValue(x); @@ -154,6 +158,7 @@ public ref struct GumpLayoutBuilder public int AddHtmlPlaceholder(int x, int y, int width, int height, bool background = false, bool scrollbar = false) { + _hasVisualElements = true; GrowIfNeeded(11 + 8 + 36 + 10); WriteStart("htmlgump"u8); WriteValue(x); @@ -172,6 +177,7 @@ public ref struct GumpLayoutBuilder public void AddHtml(int x, int y, int width, int height, int text, bool background = false, bool scrollbar = false) { + _hasVisualElements = true; GrowIfNeeded(11 + 8 + 45 + 4); WriteStart("htmlgump"u8); WriteValue(x); @@ -188,6 +194,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int number, bool background = false, bool scrollbar = false ) { + _hasVisualElements = true; GrowIfNeeded(11 + 11 + 45 + 4); WriteStart("xmfhtmlgump"u8); WriteValue(x); @@ -204,6 +211,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int number, int color, bool background = false, bool scrollbar = false ) { + _hasVisualElements = true; GrowIfNeeded(12 + 16 + 45 + 5 + 4); WriteStart("xmfhtmlgumpcolor"u8); WriteValue(x); @@ -220,6 +228,7 @@ public ref struct GumpLayoutBuilder public void AddHtmlLocalized(int x, int y, int width, int height, int number, ReadOnlySpan args, int color, bool background = false, bool scrollbar = false) { + _hasVisualElements = true; GrowIfNeeded(12 + 10 + 45 + 5 + 4 + (args.Length > 0 ? 3 + args.Length : 0)); WriteStart("xmfhtmltok"u8); WriteValue(x); @@ -254,6 +263,7 @@ public ref struct GumpLayoutBuilder public void AddImage(int x, int y, int gumpId, int hue = 0, ReadOnlySpan cls = default) { + _hasVisualElements = true; GrowIfNeeded(7 + 7 + 36 + (hue != 0 ? 14 : 0) + (cls.Length > 0 ? 7 + cls.Length : 0)); WriteStart("gumppic"u8); WriteValue(x); @@ -294,6 +304,7 @@ public ref struct GumpLayoutBuilder public void AddImageTiledButton(int x, int y, int normalId, int pressedId, int buttonId, GumpButtonType type, int param, int itemId, int hue, int width, int height, int localizedTooltip = -1) { + _hasVisualElements = true; GrowIfNeeded(15 + 13 + 90 + 2); WriteStart("buttontileart"u8); WriteValue(x); @@ -319,6 +330,7 @@ public ref struct GumpLayoutBuilder public void AddImageTiled(int x, int y, int width, int height, int gumpId) { + _hasVisualElements = true; GrowIfNeeded(9 + 12 + 45); WriteStart("gumppictiled"u8); WriteValue(x); @@ -331,6 +343,7 @@ public ref struct GumpLayoutBuilder public void AddItem(int x, int y, int itemId, int hue = 0) { + _hasVisualElements = true; GrowIfNeeded(7 + 36 + (hue != 0 ? 20 : 7)); WriteStart(hue == 0 ? "tilepic"u8 : "tilepichue"u8); WriteValue(x); @@ -355,6 +368,7 @@ public ref struct GumpLayoutBuilder public int AddLabelPlaceholder(int x, int y, int hue) { + _hasVisualElements = true; GrowIfNeeded(8 + 4 + 27 + 6); WriteStart("text"u8); WriteValue(x); @@ -368,6 +382,7 @@ public ref struct GumpLayoutBuilder public void AddLabel(int x, int y, int hue, int text) { + _hasVisualElements = true; GrowIfNeeded(8 + 4 + 36 + 6); WriteStart("text"u8); WriteValue(x); @@ -379,6 +394,7 @@ public ref struct GumpLayoutBuilder public int AddLabelCroppedPlaceholder(int x, int y, int width, int height, int hue) { + _hasVisualElements = true; GrowIfNeeded(10 + 11 + 45 + 6); WriteStart("croppedtext"u8); WriteValue(x); @@ -394,6 +410,7 @@ public ref struct GumpLayoutBuilder public void AddLabelCropped(int x, int y, int width, int height, int hue, int text) { + _hasVisualElements = true; GrowIfNeeded(10 + 11 + 54 + 6); WriteStart("croppedtext"u8); WriteValue(x); @@ -430,6 +447,7 @@ public ref struct GumpLayoutBuilder public void AddRadio(int x, int y, int inactiveId, int activeId, bool selected, int switchId) { + _hasVisualElements = true; GrowIfNeeded(10 + 5 + 45 + 2); WriteStart("radio"u8); WriteValue(x); @@ -445,6 +463,7 @@ public ref struct GumpLayoutBuilder public void AddSpriteImage(int x, int y, int gumpId, int width, int height, int sx, int sy) { + _hasVisualElements = true; GrowIfNeeded(11 + 8 + 63); WriteStart("picinpic"u8); WriteValue(x); @@ -461,6 +480,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId ) { + _hasVisualElements = true; GrowIfNeeded(11 + 9 + 54 + 6); WriteStart("textentry"u8); WriteValue(x); @@ -481,6 +501,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId, int initialText ) { + _hasVisualElements = true; GrowIfNeeded(11 + 9 + 63 + 6); WriteStart("textentry"u8); WriteValue(x); @@ -499,6 +520,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId, int size = 0 ) { + _hasVisualElements = true; GrowIfNeeded(12 + 16 + 63 + 6); WriteStart("textentrylimited"u8); WriteValue(x); @@ -520,6 +542,7 @@ public ref struct GumpLayoutBuilder int x, int y, int width, int height, int hue, int entryId, int initialText, int size = 0 ) { + _hasVisualElements = true; GrowIfNeeded(12 + 16 + 72); WriteStart("textentrylimited"u8); WriteValue(x); diff --git a/Projects/UOContent/Gumps/Base/Legacy/Gump.cs b/Projects/UOContent/Gumps/Base/Legacy/Gump.cs index 5cfb1f14a..3be1ddebf 100644 --- a/Projects/UOContent/Gumps/Base/Legacy/Gump.cs +++ b/Projects/UOContent/Gumps/Base/Legacy/Gump.cs @@ -249,6 +249,7 @@ public class Gump : BaseGump { _textEntries = 0; _switches = 0; + HasVisualElements = false; var layoutWriter = new SpanWriter(_layoutBuffer); @@ -274,6 +275,7 @@ public class Gump : BaseGump foreach (var entry in Entries) { + HasVisualElements |= IsVisualEntry(entry); entry.AppendTo(ref layoutWriter, _stringsList, ref _textEntries, ref _switches); } @@ -311,6 +313,9 @@ public class Gump : BaseGump } } + private static bool IsVisualEntry(GumpEntry entry) => + entry is not (GumpAlphaRegion or GumpECHandleInput or GumpGroup or GumpItemProperty or GumpMasterGump or GumpPage or GumpTooltip); + protected void Reset() { _switches = 0; diff --git a/Projects/UOContent/Gumps/Base/StaticGump.cs b/Projects/UOContent/Gumps/Base/StaticGump.cs index a10491618..0ab2df5e0 100644 --- a/Projects/UOContent/Gumps/Base/StaticGump.cs +++ b/Projects/UOContent/Gumps/Base/StaticGump.cs @@ -30,6 +30,7 @@ public abstract class StaticGump : BaseGump where TSelf : StaticGump : BaseGump where TSelf : StaticGump : BaseGump where TSelf : StaticGump _gumpBuilder._switches; public int TextEntries => _gumpBuilder._textEntries; + internal bool HasVisualElements => _gumpBuilder._hasVisualElements; [MethodImpl(MethodImplOptions.AggressiveInlining)] public StaticGumpBuilder() diff --git a/Projects/UOContent/Items/Addons/BaseAddon.cs b/Projects/UOContent/Items/Addons/BaseAddon.cs index 6b2abbc66..f2a662f6a 100644 --- a/Projects/UOContent/Items/Addons/BaseAddon.cs +++ b/Projects/UOContent/Items/Addons/BaseAddon.cs @@ -63,22 +63,14 @@ namespace Server.Items } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } Item IAddon.Deed => Deed; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs index ad655c24e..8554ad4e5 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainer.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainer.cs @@ -41,22 +41,14 @@ namespace Server.Items } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(1, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } public virtual bool RetainDeedHue => false; diff --git a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs index c7e08e955..7d5014dee 100644 --- a/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs +++ b/Projects/UOContent/Items/Addons/BaseAddonContainerDeed.cs @@ -23,22 +23,14 @@ public abstract partial class BaseAddonContainerDeed : Item, ICraftable public abstract BaseAddonContainer Addon { get; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(_resource); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(_resource); } public virtual int OnCraft( diff --git a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs index f84c959cf..decf3ca65 100644 --- a/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillEastAddon.cs @@ -48,17 +48,19 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurFlour + [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _curFlour; + + private bool AllowCurFlourChange(ref int value) { - get => _curFlour; - set - { - _curFlour = Math.Clamp(value, 0, MaxFlour); - UpdateStage(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxFlour); + return true; + } + + private void OnCurFlourChanged(int oldValue, int newValue) + { + UpdateStage(); } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs index cbd0e2cc3..fa48aa83c 100644 --- a/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs +++ b/Projects/UOContent/Items/Addons/FlourMillSouthAddon.cs @@ -35,16 +35,19 @@ namespace Server.Items [CommandProperty(AccessLevel.GameMaster)] public int MaxFlour => 2; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurFlour + [SerializableField(0, fieldChanged: nameof(OnCurFlourChanged), allowFieldChange: nameof(AllowCurFlourChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _curFlour; + + private bool AllowCurFlourChange(ref int value) { - get => _curFlour; - set - { - _curFlour = Math.Max(0, Math.Min(value, MaxFlour)); - UpdateStage(); - } + value = Math.Max(0, Math.Min(value, MaxFlour)); + return true; + } + + private void OnCurFlourChanged(int oldValue, int newValue) + { + UpdateStage(); } public void StartWorking(Mobile from) diff --git a/Projects/UOContent/Items/Addons/SHTeleporter.cs b/Projects/UOContent/Items/Addons/SHTeleporter.cs index f34f9cb85..e6ce612b4 100644 --- a/Projects/UOContent/Items/Addons/SHTeleporter.cs +++ b/Projects/UOContent/Items/Addons/SHTeleporter.cs @@ -25,35 +25,27 @@ namespace Server.Items _teleOffset = offset; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public bool Active - { - get => _active; - set - { - _active = value; + [SerializableField(0, fieldChanged: nameof(OnActiveChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _active; - if (Addon is SHTeleporter sourceAddon) - { - sourceAddon.ChangeActive(value); - } + private void OnActiveChanged(bool oldValue, bool newValue) + { + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeActive(newValue); } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public SHTeleComponent TeleDest - { - get => _teleDest; - set - { - _teleDest = value; + [SerializableField(1, fieldChanged: nameof(OnTeleDestChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private SHTeleComponent _teleDest; - if (Addon is SHTeleporter sourceAddon) - { - sourceAddon.ChangeDest(value); - } + private void OnTeleDestChanged(SHTeleComponent oldValue, SHTeleComponent newValue) + { + if (Addon is SHTeleporter sourceAddon) + { + sourceAddon.ChangeDest(newValue); } } diff --git a/Projects/UOContent/Items/Aquarium/Aquarium.cs b/Projects/UOContent/Items/Aquarium/Aquarium.cs index 749c677c2..054fea588 100644 --- a/Projects/UOContent/Items/Aquarium/Aquarium.cs +++ b/Projects/UOContent/Items/Aquarium/Aquarium.cs @@ -31,9 +31,9 @@ namespace Server.Items private bool m_EvaluateDay; [SerializableField(0, setter: "private")] + [DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; - [DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); diff --git a/Projects/UOContent/Items/Aquarium/AquariumState.cs b/Projects/UOContent/Items/Aquarium/AquariumState.cs index cdd59d9d9..75fe6fc6f 100644 --- a/Projects/UOContent/Items/Aquarium/AquariumState.cs +++ b/Projects/UOContent/Items/Aquarium/AquariumState.cs @@ -30,19 +30,14 @@ namespace Server.Items public AquariumState(Aquarium parent) => _aquarium = parent; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int State + [SerializableField(0, allowFieldChange: nameof(AllowStateChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _state; + + private bool AllowStateChange(ref int value) { - get => _state; - set - { - if (_state != value) - { - _state = Math.Clamp(value, 0, 4); - MarkDirty(); - } - } + value = Math.Clamp(value, 0, 4); + return true; } [SerializableField(1)] diff --git a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs index 3135850a9..f15a7fd6a 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.Migrations.cs @@ -1,9 +1,40 @@ +using System; using AMA = Server.Items.ArmorMeditationAllowance; namespace Server.Items; public partial class BaseArmor { + // PlayerConstructed moved onto Item + private void MigrateFrom(V9Content content) + { + _attributes = content.Attributes ?? AttributesDefaultValue(); + _armorAttributes = content.ArmorAttributes ?? ArmorAttributesDefaultValue(); + _physicalBonus = content.PhysicalBonus ?? 0; + _fireBonus = content.FireBonus ?? 0; + _coldBonus = content.ColdBonus ?? 0; + _poisonBonus = content.PoisonBonus ?? 0; + _energyBonus = content.EnergyBonus ?? 0; + _identified = content.Identified; + _maxHitPoints = content.MaxHitPoints ?? 0; + _hitPoints = content.HitPoints ?? 0; + _crafter = content.Crafter; + _quality = content.Quality ?? ArmorQuality.Regular; + _durability = content.Durability ?? ArmorDurabilityLevel.Regular; + _protectionLevel = content.ProtectionLevel ?? ArmorProtectionLevel.Regular; + _resource = content.Resource ?? DefaultResource; + _armorBase = content.BaseArmorRating ?? -1; + _strBonus = content.StrBonus ?? -1; + _dexBonus = content.DexBonus ?? -1; + _intBonus = content.IntBonus ?? -1; + _strReq = content.StrRequirement ?? -1; + _dexReq = content.DexRequirement ?? -1; + _intReq = content.IntRequirement ?? -1; + _meditate = content.MeditationAllowance ?? (AMA)(-1); + _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); + PlayerConstructed = content.PlayerConstructed; + } + private void MigrateFrom(V8Content content) { _attributes = content.Attributes ?? AttributesDefaultValue(); @@ -29,7 +60,7 @@ public partial class BaseArmor _intReq = content.IntRequirement ?? -1; _meditate = content.MeditationAllowance ?? (AMA)(-1); _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); - _playerConstructed = content.PlayerConstructed; + PlayerConstructed = content.PlayerConstructed; } // Version 7 (pre-codegen) @@ -162,4 +193,37 @@ public partial class BaseArmor PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); } + + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + Attributes = 0x00000001, + ArmorAttributes = 0x00000002, + PhysicalBonus = 0x00000004, + FireBonus = 0x00000008, + ColdBonus = 0x00000010, + PoisonBonus = 0x00000020, + EnergyBonus = 0x00000040, + Identified = 0x00000080, + MaxHitPoints = 0x00000100, + HitPoints = 0x00000200, + Crafter = 0x00000400, + Quality = 0x00000800, + Durability = 0x00001000, + Protection = 0x00002000, + Resource = 0x00004000, + BaseArmor = 0x00008000, + StrBonus = 0x00010000, + DexBonus = 0x00020000, + IntBonus = 0x00040000, + StrReq = 0x00080000, + DexReq = 0x00100000, + IntReq = 0x00200000, + MedAllowance = 0x00400000, + SkillBonuses = 0x00800000, + PlayerConstructed = 0x01000000 + } } diff --git a/Projects/UOContent/Items/Armor/BaseArmor.cs b/Projects/UOContent/Items/Armor/BaseArmor.cs index 7f01648a0..f4062326d 100644 --- a/Projects/UOContent/Items/Armor/BaseArmor.cs +++ b/Projects/UOContent/Items/Armor/BaseArmor.cs @@ -12,101 +12,98 @@ using AMT = Server.Items.ArmorMaterialType; namespace Server.Items { - [SerializationGenerator(9, false)] + [SerializationGenerator(10, false)] public abstract partial class BaseArmor : Item, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem, IIdentifiable { [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeArmorAttributes), nameof(ArmorAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _armorAttributes; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeArmorAttributes() => !_armorAttributes.IsEmpty; - [SerializableFieldDefault(1)] private AosArmorAttributes ArmorAttributesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializePhysicalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _physicalBonus; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializePhysicalBonus() => _physicalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeFireBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _fireBonus; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeFireBonus() => _fireBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeColdBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _coldBonus; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeColdBonus() => _coldBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializePoisonBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonBonus; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializePoisonBonus() => _poisonBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeEnergyBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _energyBonus; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeEnergyBonus() => _energyBonus != 0; [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; - [SerializableFieldSaveFlag(7)] private bool ShouldSerializeIdentified() => _identified; [EncodedInt] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeResource() => _resource != DefaultResource; // Field 15 @@ -135,22 +132,14 @@ namespace Server.Items [SerializedIgnoreDupe] [SerializableField(23, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(23)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(23)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); - [SerializableField(24)] - [SerializedCommandProperty(AccessLevel.GameMaster)] - public bool _playerConstructed; - - [SerializableFieldSaveFlag(24)] - private bool ShouldSerializePlayerConstructed() => _playerConstructed; - private FactionItem m_FactionState; public BaseArmor(int itemID) : base(itemID) @@ -197,6 +186,7 @@ namespace Server.Items public virtual int OldIntReq => 0; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeArmorQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public ArmorQuality Quality { @@ -209,13 +199,12 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeArmorQuality() => _quality != ArmorQuality.Regular; - [SerializableFieldDefault(11)] private ArmorQuality QualityDefaultValue() => ArmorQuality.Regular; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeDurability))] [CommandProperty(AccessLevel.GameMaster)] public ArmorDurabilityLevel Durability { @@ -228,33 +217,24 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDurability() => _durability != ArmorDurabilityLevel.Regular; - [SerializableProperty(13)] - [CommandProperty(AccessLevel.GameMaster)] - public ArmorProtectionLevel ProtectionLevel + [SerializableField(13, fieldChanged: nameof(OnProtectionLevelChanged))] + [SaveFlag(nameof(ShouldSerializeProtectionLevel))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private ArmorProtectionLevel _protectionLevel; + + private void OnProtectionLevelChanged(ArmorProtectionLevel oldValue, ArmorProtectionLevel newValue) { - get => _protectionLevel; - set - { - if (_protectionLevel != value) - { - _protectionLevel = value; - - Invalidate(); - InvalidateProperties(); - - (Parent as Mobile)?.UpdateResistances(); - this.MarkDirty(); - } - } + Invalidate(); + (Parent as Mobile)?.UpdateResistances(); } - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeProtectionLevel() => _protectionLevel != ArmorProtectionLevel.Regular; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -280,11 +260,11 @@ namespace Server.Items } } - [SerializableFieldDefault(14)] private CraftResource ResourceDefaultValue() => DefaultResource; [EncodedInt] [SerializableProperty(15, useField: nameof(_armorBase))] + [SaveFlag(nameof(ShouldSerializeArmorBase), nameof(ArmorBaseDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int BaseArmorRating { @@ -297,10 +277,8 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeArmorBase() => _armorBase != -1; - [SerializableFieldDefault(15)] private int ArmorBaseDefaultValue() => -1; public double BaseArmorRatingScaled => BaseArmorRating * ArmorScalar; @@ -350,6 +328,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(16, useField: nameof(_strBonus))] + [SaveFlag(nameof(ShouldSerializeStrBonus), nameof(StrBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrBonus { @@ -362,14 +341,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeStrBonus() => _strBonus != -1; - [SerializableFieldDefault(16)] private int StrBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(17, useField: nameof(_dexBonus))] + [SaveFlag(nameof(ShouldSerializeDexBonus), nameof(DexBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexBonus { @@ -382,14 +360,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeDexBonus() => _dexBonus != -1; - [SerializableFieldDefault(17)] private int DexBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(18, useField: nameof(_intBonus))] + [SaveFlag(nameof(ShouldSerializeIntBonus), nameof(IntBonusDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntBonus { @@ -402,14 +379,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeIntBonus() => _intBonus != -1; - [SerializableFieldDefault(18)] private int IntBonusDefaultValue() => -1; [EncodedInt] [SerializableProperty(19, useField: nameof(_strReq))] + [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -422,14 +398,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeStrReq() => _strReq != -1; - [SerializableFieldDefault(19)] private int StrReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(20, useField: nameof(_dexReq))] + [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -442,14 +417,13 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(20)] private bool ShouldSerializeDexReq() => _dexReq != -1; - [SerializableFieldDefault(20)] private int DexReqDefaultValue() => -1; [EncodedInt] [SerializableProperty(21, useField: nameof(_intReq))] + [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -462,13 +436,12 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(21)] private bool ShouldSerializeIntReq() => _intReq != -1; - [SerializableFieldDefault(21)] private int IntReqDefaultValue() => -1; [SerializableProperty(22, useField: nameof(_meditate))] + [SaveFlag(nameof(ShouldSerializeMeditationAllowance))] [CommandProperty(AccessLevel.GameMaster)] public AMA MeditationAllowance { @@ -480,7 +453,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(22)] private bool ShouldSerializeMeditationAllowance() => _meditate >= AMA.All; public virtual double ArmorScalar @@ -570,7 +542,6 @@ namespace Server.Items var resourceType = typeRes ?? craftItem.Resources[0].ItemType; Resource = CraftResources.GetFromType(resourceType); - PlayerConstructed = true; Identified = true; var context = craftSystem.GetContext(from); @@ -697,6 +668,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(9)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -724,7 +696,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; @@ -1026,8 +997,6 @@ namespace Server.Items (Parent as Mobile)?.Delta(MobileDelta.Armor); // Tell them armor rating has changed } - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - [AfterDeserialization] private void AfterDeserialization() { @@ -1514,35 +1483,5 @@ namespace Server.Items }; } - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - Attributes = 0x00000001, - ArmorAttributes = 0x00000002, - PhysicalBonus = 0x00000004, - FireBonus = 0x00000008, - ColdBonus = 0x00000010, - PoisonBonus = 0x00000020, - EnergyBonus = 0x00000040, - Identified = 0x00000080, - MaxHitPoints = 0x00000100, - HitPoints = 0x00000200, - Crafter = 0x00000400, - Quality = 0x00000800, - Durability = 0x00001000, - Protection = 0x00002000, - Resource = 0x00004000, - BaseArmor = 0x00008000, - StrBonus = 0x00010000, - DexBonus = 0x00020000, - IntBonus = 0x00040000, - StrReq = 0x00080000, - DexReq = 0x00100000, - IntReq = 0x00200000, - MedAllowance = 0x00400000, - SkillBonuses = 0x00800000, - PlayerConstructed = 0x01000000 - } } } diff --git a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs index 4b0fb791f..e44967f0b 100644 --- a/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs +++ b/Projects/UOContent/Items/Armor/Glasses/ElvenGlasses.cs @@ -31,13 +31,12 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] public AosWeaponAttributes _weaponAttributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; - [SerializableFieldDefault(0)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); public override void AppendChildNameProperties(IPropertyList list) diff --git a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs index f9abd485b..aa023cdc3 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeafGloves.cs @@ -33,32 +33,26 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs index a1ded151b..297bd8ed1 100644 --- a/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs +++ b/Projects/UOContent/Items/Armor/Leather/LeatherGloves.cs @@ -32,32 +32,26 @@ namespace Server.Items public override ArmorMeditationAllowance DefMedAllowance => ArmorMeditationAllowance.All; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Books/BaseBook.cs b/Projects/UOContent/Items/Books/BaseBook.cs index 47f368754..3d9b9f7b8 100644 --- a/Projects/UOContent/Items/Books/BaseBook.cs +++ b/Projects/UOContent/Items/Books/BaseBook.cs @@ -19,41 +19,38 @@ namespace Server.Items [InternString] [InvalidateProperties] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeTitle), nameof(TitleDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _title; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeTitle() => _title != DefaultContent?.Title; - [SerializableFieldDefault(1)] private string TitleDefaultValue() => DefaultContent?.Title; [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeAuthor), nameof(AuthorDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _author; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeAuthor() => _author != DefaultContent?.Author; - [SerializableFieldDefault(2)] private string AuthorDefaultValue() => DefaultContent?.Author; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeWritable))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _writable; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeWritable() => _writable; [SerializedIgnoreDupe] [SerializableField(4, setter: "protected")] + [SaveFlag(nameof(ShouldSerializePages), nameof(PagesDefaultValue))] private BookPageInfo[] _pages; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializePages() => DefaultContent?.IsMatch(_pages) != true; - [SerializableFieldDefault(4)] private BookPageInfo[] PagesDefaultValue() => DefaultContent?.Copy() ?? Array.Empty(); [Constructible] diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs index e267cd1aa..7c431f076 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.Migrations.cs @@ -1,7 +1,25 @@ +using System; + namespace Server.Items; public partial class BaseClothing { + // PlayerConstructed moved onto Item + private void MigrateFrom(V7Content content) + { + _resource = content.Resource ?? DefaultResource; + _attributes = content.Attributes ?? AttributesDefaultValue(); + _clothingAttributes = content.ClothingAttributes ?? ClothingAttributesDefaultValue(); + _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); + _resistances = content.Resistances ?? ResistancesDefaultValue(); + _maxHitPoints = content.MaxHitPoints ?? 0; + _hitPoints = content.HitPoints ?? 0; + PlayerConstructed = content.PlayerConstructed; + _crafter = content.Crafter; + _quality = content.Quality ?? ClothingQuality.Regular; + _strReq = content.StrRequirement ?? -1; + } + private void MigrateFrom(V6Content content) { _resource = content.RawResource ?? DefaultResource; @@ -10,7 +28,7 @@ public partial class BaseClothing _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); _resistances = content.Resistances ?? ResistancesDefaultValue(); _maxHitPoints = content.MaxHitPoints ?? 0; - _playerConstructed = content.PlayerConstructed; + PlayerConstructed = content.PlayerConstructed; Timer.DelayCall((item, crafter) => item._crafter = crafter?.RawName, this, content.Crafter); _quality = content.Quality ?? ClothingQuality.Regular; _strReq = content.StrRequirement ?? -1; @@ -85,4 +103,23 @@ public partial class BaseClothing PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); } + + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + Resource = 0x00000001, + Attributes = 0x00000002, + ClothingAttributes = 0x00000004, + SkillBonuses = 0x00000008, + Resistances = 0x00000010, + MaxHitPoints = 0x00000020, + HitPoints = 0x00000040, + PlayerConstructed = 0x00000080, + Crafter = 0x00000100, + Quality = 0x00000200, + StrReq = 0x00000400 + } } diff --git a/Projects/UOContent/Items/Clothing/BaseClothing.cs b/Projects/UOContent/Items/Clothing/BaseClothing.cs index 725eed100..983568d2d 100644 --- a/Projects/UOContent/Items/Clothing/BaseClothing.cs +++ b/Projects/UOContent/Items/Clothing/BaseClothing.cs @@ -22,90 +22,78 @@ namespace Server.Items int MaxArcaneCharges { get; set; } } - [SerializationGenerator(7, false)] + [SerializationGenerator(8, false)] public abstract partial class BaseClothing : Item, IDyable, IScissorable, IFactionItem, ICraftable, IWearableDurability, IAosItem { - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeResource() => _resource != DefaultResource; [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(1)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(2, setter: "private")] + [SaveFlag(nameof(ShouldSerializeClothingAttributes), nameof(ClothingAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosArmorAttributes _clothingAttributes; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeClothingAttributes() => !_clothingAttributes.IsEmpty; - [SerializableFieldDefault(2)] private AosArmorAttributes ClothingAttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(3, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(3)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(4, setter: "private")] + [SaveFlag(nameof(ShouldSerializeResistances), nameof(ResistancesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _resistances; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeResistances() => !_resistances.IsEmpty; - [SerializableFieldDefault(4)] private AosElementAttributes ResistancesDefaultValue() => new(this); [EncodedInt] [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; - [SerializableField(7)] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _playerConstructed; - - [SerializableFieldSaveFlag(7)] - private bool ShouldSerializePlayerConstructed() => _playerConstructed; - [InvalidateProperties] - [SerializableField(8)] + [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(8)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] - [SerializableField(9)] + [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeQuality))] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality = ClothingQuality.Regular; - [SerializableFieldSaveFlag(9)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; - // Field 10 + // Field 9 private int _strReq = -1; private FactionItem _factionState; @@ -125,21 +113,19 @@ namespace Server.Items Resistances = new AosElementAttributes(this); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SaveFlag(nameof(ShouldSerializeResource))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - InvalidateProperties(); - this.MarkDirty(); - } + Hue = CraftResources.GetHue(_resource); } - [SerializableProperty(10, useField: nameof(_strReq))] + [SerializableProperty(9, useField: nameof(_strReq))] + [SaveFlag(nameof(ShouldSerializeStrReq))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -152,7 +138,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(10)] private bool ShouldSerializeStrReq() => _strReq != -1; public virtual CraftResource DefaultResource => CraftResource.None; @@ -207,8 +192,6 @@ namespace Server.Items Hue = resHue; } - PlayerConstructed = true; - var context = craftSystem.GetContext(from); if (context?.DoNotColor == true) @@ -308,6 +291,7 @@ namespace Server.Items [EncodedInt] [SerializableProperty(6)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -333,7 +317,6 @@ namespace Server.Items } } - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeHitPoints() => _hitPoints != 0; public virtual int InitMinHits => 0; @@ -889,8 +872,6 @@ namespace Server.Items InvalidateProperties(); } - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - [AfterDeserialization] private void AfterDeserialization() { @@ -911,21 +892,5 @@ namespace Server.Items } } - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - Resource = 0x00000001, - Attributes = 0x00000002, - ClothingAttributes = 0x00000004, - SkillBonuses = 0x00000008, - Resistances = 0x00000010, - MaxHitPoints = 0x00000020, - HitPoints = 0x00000040, - PlayerConstructed = 0x00000080, - Crafter = 0x00000100, - Quality = 0x00000200, - StrReq = 0x00000400 - } } } diff --git a/Projects/UOContent/Items/Clothing/Cloaks.cs b/Projects/UOContent/Items/Clothing/Cloaks.cs index 67f572d9d..a92e8fc8c 100644 --- a/Projects/UOContent/Items/Clothing/Cloaks.cs +++ b/Projects/UOContent/Items/Clothing/Cloaks.cs @@ -22,34 +22,26 @@ namespace Server.Items public override double DefaultWeight => 5.0; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - this.MarkDirty(); - InvalidateProperties(); - Update(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - this.MarkDirty(); - InvalidateProperties(); - Update(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/OuterTorso.cs b/Projects/UOContent/Items/Clothing/OuterTorso.cs index 4fda45bb5..f3b0b077f 100644 --- a/Projects/UOContent/Items/Clothing/OuterTorso.cs +++ b/Projects/UOContent/Items/Clothing/OuterTorso.cs @@ -37,21 +37,22 @@ namespace Server.Items public override double DefaultWeight => 3.0; } - [SerializationGenerator(3, false)] + [SerializationGenerator(4, false)] public partial class DeathRobe : Robe { private static readonly TimeSpan m_DefaultDecayTime = TimeSpan.FromMinutes(1.0); - [TimerDrift] [SerializableField(0)] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(0)] - private void DeserializeDecayTimer(TimeSpan delay) + private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + + private void MigrateFrom(V3Content content) { - if (delay != TimeSpan.MinValue) + if (content.DecayTimerDelay != TimeSpan.MinValue) { - BeginDecay(delay); + DeserializeDecayTimer(content.DecayTimerDelay); } } @@ -324,34 +325,26 @@ namespace Server.Items public override double DefaultWeight => 3.0; + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int CurArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _curArcaneCharges; + + private void OnCurArcaneChargesChanged(int oldValue, int newValue) { - get => _curArcaneCharges; - set - { - _curArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Clothing/Shoes.cs b/Projects/UOContent/Items/Clothing/Shoes.cs index 4d80b4699..f791b3a62 100644 --- a/Projects/UOContent/Items/Clothing/Shoes.cs +++ b/Projects/UOContent/Items/Clothing/Shoes.cs @@ -54,11 +54,10 @@ namespace Server.Items { [EncodedInt] [InvalidateProperties] - [SerializableField(0)] + [SerializableField(0, fieldChanged: nameof(OnCurArcaneChargesChanged))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _curArcaneCharges; - [SerializableFieldChanged(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private void OnCurArcaneChargesChanged(int oldValue, int newValue) => Update(); @@ -71,19 +70,15 @@ namespace Server.Items public override CraftResource DefaultResource => CraftResource.RegularLeather; + [SerializableField(1, fieldChanged: nameof(OnMaxArcaneChargesChanged))] [EncodedInt] - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int MaxArcaneCharges + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _maxArcaneCharges; + + private void OnMaxArcaneChargesChanged(int oldValue, int newValue) { - get => _maxArcaneCharges; - set - { - _maxArcaneCharges = value; - InvalidateProperties(); - Update(); - this.MarkDirty(); - } + Update(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs index dc3449c24..ef779760b 100644 --- a/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs +++ b/Projects/UOContent/Items/Construction/Doors/BaseDoor.cs @@ -74,43 +74,31 @@ public abstract partial class BaseDoor : Item, ILockable, ITelekinesisable Movable = false; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public bool Open + [SerializableField(1, fieldChanged: nameof(OnOpenChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _open; + + private void OnOpenChanged(bool oldValue, bool newValue) { - get => _open; - set + ItemID = _open ? _openedId : _closedId; + if (_open) { - if (_open != value) - { - _open = value; - - ItemID = _open ? _openedId : _closedId; - - if (_open) - { - Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); - } - else - { - Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); - } - - Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); - - if (_open) - { - _timer ??= new InternalTimer(this); - _timer.Start(); - } - else - { - _timer.Stop(); - _timer = null; - } - - this.MarkDirty(); - } + Location = new Point3D(X + _offset.X, Y + _offset.Y, Z + _offset.Z); + } + else + { + Location = new Point3D(X - _offset.X, Y - _offset.Y, Z - _offset.Z); + } + Effects.PlaySound(this, _open ? OpenedSound : ClosedSound); + if (_open) + { + _timer ??= new InternalTimer(this); + _timer.Start(); + } + else + { + _timer.Stop(); + _timer = null; } } diff --git a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs index a9da67dbb..da4d8b77f 100644 --- a/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs +++ b/Projects/UOContent/Items/Containers/Fillable Containers/FillableContainer.cs @@ -3,19 +3,22 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public abstract partial class FillableContainer : LockableContainer { - [TimerDrift] [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeRespawnTimer))] private Timer _respawnTimer; - [DeserializeTimerField(1)] - private void DeserializeRespawnTimer(TimeSpan delay) + private void DeserializeRespawnTimer(TimeSpan delay) => _respawnTimer = Timer.DelayCall(delay, Respawn); + + private void MigrateFrom(V2Content content) { - if (delay > TimeSpan.MinValue) + _contentType = content.ContentType; + + if (content.RespawnTimerDelay != TimeSpan.MinValue) { - _respawnTimer = Timer.DelayCall(delay, Respawn); + DeserializeRespawnTimer(content.RespawnTimerDelay); } } diff --git a/Projects/UOContent/Items/Containers/MarkContainer.cs b/Projects/UOContent/Items/Containers/MarkContainer.cs index fa3f7e440..04e7c89ea 100644 --- a/Projects/UOContent/Items/Containers/MarkContainer.cs +++ b/Projects/UOContent/Items/Containers/MarkContainer.cs @@ -3,14 +3,13 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class MarkContainer : LockableContainer { - [TimerDrift] [SerializableField(1, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeRelockTimer))] private InternalTimer _relockTimer; - [DeserializeTimerField(1)] private void DeserializeRelockTimer(TimeSpan delay) { if (!Locked && _autoLock) @@ -19,6 +18,19 @@ public partial class MarkContainer : LockableContainer } } + private void MigrateFrom(V0Content content) + { + _autoLock = content.AutoLock; + _targetMap = content.TargetMap; + _target = content.Target; + _description = content.Description; + + if (content.RelockTimerDelay != TimeSpan.MinValue) + { + DeserializeRelockTimer(content.RelockTimerDelay); + } + } + [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private Map _targetMap; @@ -50,23 +62,19 @@ public partial class MarkContainer : LockableContainer } } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public bool AutoLock - { - get => _autoLock; - set - { - _autoLock = value; + [SerializableField(0, fieldChanged: nameof(OnAutoLockChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _autoLock; - if (!_autoLock) - { - StopTimer(); - } - else if (!Locked) - { - _relockTimer ??= new InternalTimer(this); - } + private void OnAutoLockChanged(bool oldValue, bool newValue) + { + if (!_autoLock) + { + StopTimer(); + } + else if (!Locked) + { + _relockTimer ??= new InternalTimer(this); } } diff --git a/Projects/UOContent/Items/Containers/TreasureMapChest.cs b/Projects/UOContent/Items/Containers/TreasureMapChest.cs index b59911a8a..a1aafa0b3 100644 --- a/Projects/UOContent/Items/Containers/TreasureMapChest.cs +++ b/Projects/UOContent/Items/Containers/TreasureMapChest.cs @@ -9,10 +9,11 @@ using Server.Network; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(4, false)] public partial class TreasureMapChest : LockableContainer { [Tidy] + [CanBeNull] [SerializableField(0, setter: "private")] private List _guardians; @@ -28,12 +29,11 @@ public partial class TreasureMapChest : LockableContainer [SerializedCommandProperty(AccessLevel.GameMaster)] private int _level; - [TimerDrift] [SerializableField(4)] [SerializedCommandProperty(AccessLevel.GameMaster)] + [DeserializeTimer(nameof(DeserializeExpireTimer))] private Timer _expireTimer; - [DeserializeTimerField(4)] private void DeserializeExpireTimer(TimeSpan delay) { if (!_temporary) @@ -42,11 +42,29 @@ public partial class TreasureMapChest : LockableContainer } } + private void MigrateFrom(V3Content content) + { + _guardians = content.Guardians; + _temporary = content.Temporary; + _owner = content.Owner; + _level = content.Level; + _lifted = content.Lifted; + + if (content.ExpireTimerDelay != TimeSpan.MinValue) + { + DeserializeExpireTimer(content.ExpireTimerDelay); + } + } + [Tidy] + [CanBeNull] [SerializableField(5, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private HashSet _lifted; + // False only while the constructor fills the chest; deserialized chests are always filled. + private bool _filled; + [Constructible] public TreasureMapChest(int level) : this(null, level) { @@ -58,10 +76,10 @@ public partial class TreasureMapChest : LockableContainer _level = level; _temporary = temporary; - _guardians = []; _expireTimer = Timer.DelayCall(TimeSpan.FromHours(3.0), Delete); Fill(this, level); + _filled = true; } public override int LabelNumber => 3000541; @@ -182,7 +200,6 @@ public partial class TreasureMapChest : LockableContainer 2 => 76, 3 => 84, 4 => 92, - 5 => 100, _ => 100 }; @@ -300,14 +317,17 @@ public partial class TreasureMapChest : LockableContainer if (_level == 0 && from.AccessLevel < AccessLevel.GameMaster) { - for (var i = 0; i < _guardians.Count; i++) + if (_guardians != null) { - var m = _guardians[i]; - if (m.Alive) + for (var i = 0; i < _guardians.Count; i++) { - // You must first kill the guardians before you may open this chest. - from.SendLocalizedMessage(1046448); - return true; + var m = _guardians[i]; + if (m.Alive) + { + // You must first kill the guardians before you may open this chest. + from.SendLocalizedMessage(1046448); + return true; + } } } @@ -361,12 +381,21 @@ public partial class TreasureMapChest : LockableContainer public override bool CheckLift(Mobile from, Item item, ref LRReason reject) => CheckLoot(from, true) && base.CheckLift(from, item, ref reject); + public override void OnItemAdded(Item item) + { + base.OnItemAdded(item); + + if (_filled) + { + _lifted ??= []; + _lifted.Add(item); + } + } + public override void OnItemLifted(Mobile from, Item item) { - var notYetLifted = _lifted?.Contains(item) != true; from.RevealingAction(); - - if (notYetLifted) + if (_lifted?.Contains(item) != true) { _lifted ??= []; _lifted.Add(item); @@ -393,8 +422,6 @@ public partial class TreasureMapChest : LockableContainer private void Deserialize(IGenericReader reader, int version) { - _guardians = []; - _owner = reader.ReadEntity(); _level = reader.ReadInt(); var expireTimerNext = reader.ReadDeltaTime(); @@ -402,12 +429,34 @@ public partial class TreasureMapChest : LockableContainer _lifted = reader.ReadEntitySet(); } - [AfterDeserialization(false)] + private void MigrateFrom(V2Content content) + { + _guardians = content.Guardians; + if (_guardians.Count == 0) + { + _guardians = null; + } + _temporary = content.Temporary; + _owner = content.Owner; + _level = content.Level; + _lifted = content.Lifted; + if (_lifted.Count == 0) + { + _lifted = null; + } + + var expireTimerDelay = content.ExpireTimerDelay; + DeserializeExpireTimer(expireTimerDelay); + } + + [AfterDeserialization] private void AfterDeserialization() { + _filled = true; + if (_expireTimer == null) { - Delete(); + Timer.DelayCall(Delete); } } diff --git a/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs b/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs index 20f8f9fc6..25860f542 100644 --- a/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs +++ b/Projects/UOContent/Items/Deeds/ClothingBlessDeed.cs @@ -1,4 +1,5 @@ using ModernUO.Serialization; +using Server.Engines.Insurance; using Server.Targeting; namespace Server.Items; @@ -25,7 +26,7 @@ public class ClothingBlessTarget : Target // Create our targeting class (which w } // Check if its already newbied (blessed) - if (item.LootType == LootType.Blessed || item.BlessedFor == from || Mobile.InsuranceEnabled && item.Insured) + if (item.LootType == LootType.Blessed || item.BlessedFor == from || Insurance.Enabled && item.Insured) { from.SendLocalizedMessage(1045113); // That item is already blessed } diff --git a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs index 6599e6c56..5ed370153 100644 --- a/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs +++ b/Projects/UOContent/Items/Deeds/DragonBardingDeed.cs @@ -28,17 +28,14 @@ public partial class DragonBardingDeed : Item, ICraftable public override int LabelNumber => _exceptional ? 1053181 : 1053012; // dragon barding deed - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(value); - InvalidateProperties(); - } + Hue = CraftResources.GetHue(newValue); } public int OnCraft( diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs index 3a9f50297..85eecf17f 100644 --- a/Projects/UOContent/Items/Food/Beverage.cs +++ b/Projects/UOContent/Items/Food/Beverage.cs @@ -324,27 +324,21 @@ public abstract partial class BaseBeverage : Item, IHasQuantity [CommandProperty(AccessLevel.GameMaster)] public bool IsFull => _quantity >= MaxQuantity; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public BeverageType Content + [SerializableField(2, fieldChanged: nameof(OnContentChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private BeverageType _content; + + private void OnContentChanged(BeverageType oldValue, BeverageType newValue) { - get => _content; - set + var itemID = ComputeItemID(); + if (itemID > 0) { - _content = value; - - InvalidateProperties(); - - var itemID = ComputeItemID(); - - if (itemID > 0) - { - ItemID = itemID; - } - else - { - Delete(); - } + ItemID = itemID; + } + else + { + Delete(); } } diff --git a/Projects/UOContent/Items/Food/Cooking.cs b/Projects/UOContent/Items/Food/Cooking.cs index 6cff16d72..cd010432d 100644 --- a/Projects/UOContent/Items/Food/Cooking.cs +++ b/Projects/UOContent/Items/Food/Cooking.cs @@ -76,25 +76,25 @@ public partial class SackFlour : Item, IHasQuantity public override double DefaultWeight => 5.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Quantity + [SerializableField(0, fieldChanged: nameof(OnQuantityChanged), allowFieldChange: nameof(AllowQuantityChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _quantity; + + private bool AllowQuantityChange(ref int value) { - get => _quantity; - set + value = Math.Min(20, Math.Max(0, value)); + return true; + } + + private void OnQuantityChanged(int oldValue, int newValue) + { + if (_quantity == 0) { - _quantity = Math.Min(20, Math.Max(0, value)); - - if (_quantity == 0) - { - Delete(); - } - else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) - { - ++ItemID; - } - - this.MarkDirty(); + Delete(); + } + else if (_quantity < 20 && ItemID is 0x1039 or 0x1045) + { + ++ItemID; } } diff --git a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs index de7e663b7..89e4d6f8f 100644 --- a/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs +++ b/Projects/UOContent/Items/Games/Mahjong/MahjongGame.cs @@ -55,55 +55,33 @@ public partial class MahjongGame : Item, ISecurable public override double DefaultWeight => 5.0; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(6)] - public bool ShowScores + [SerializableField(6, fieldChanged: nameof(OnShowScoresChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _showScores; + + private void OnShowScoresChanged(bool oldValue, bool newValue) { - get => _showScores; - set + if (newValue) { - if (_showScores == value) - { - return; - } - - _showScores = value; - - if (value) - { - _players.SendPlayersPacket(true, true); - } - - _players.SendGeneralPacket(true, true); - _players.SendLocalizedMessage(value ? 1062777 : 1062778); // The dealer has enabled/disabled score display. - this.MarkDirty(); + _players.SendPlayersPacket(true, true); } + _players.SendGeneralPacket(true, true); + _players.SendLocalizedMessage(newValue ? 1062777 : 1062778); // The dealer has enabled/disabled score display. } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(7)] - public bool SpectatorVision + [SerializableField(7, fieldChanged: nameof(OnSpectatorVisionChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _spectatorVision; + + private void OnSpectatorVisionChanged(bool oldValue, bool newValue) { - get => _spectatorVision; - set + if (_players.IsInGamePlayer(_players.DealerPosition)) { - if (_spectatorVision == value) - { - return; - } - - _spectatorVision = value; - - if (_players.IsInGamePlayer(_players.DealerPosition)) - { - _players.Dealer.NetState.SendMahjongGeneralInfo(this); - } - - _players.SendTilesPacket(false, true); - _players.SendLocalizedMessage(value ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. - InvalidateProperties(); - this.MarkDirty(); + _players.Dealer.NetState.SendMahjongGeneralInfo(this); } + _players.SendTilesPacket(false, true); + _players.SendLocalizedMessage(newValue ? 1062715 : 1062716); // The dealer has enabled/disabled Spectator Vision. } private void BuildHorizontalWall( diff --git a/Projects/UOContent/Items/Jewels/BaseJewel.cs b/Projects/UOContent/Items/Jewels/BaseJewel.cs index 4eb01cbe0..e693c0aaa 100644 --- a/Projects/UOContent/Items/Jewels/BaseJewel.cs +++ b/Projects/UOContent/Items/Jewels/BaseJewel.cs @@ -93,16 +93,13 @@ public abstract partial class BaseJewel : Item, ICraftable, IAosItem } } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(2, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - } + Hue = CraftResources.GetHue(_resource); } public override int PhysicalResistance => Resistances.Physical; diff --git a/Projects/UOContent/Items/Lights/BaseLight.cs b/Projects/UOContent/Items/Lights/BaseLight.cs index d3bff4af5..4bd63ba63 100644 --- a/Projects/UOContent/Items/Lights/BaseLight.cs +++ b/Projects/UOContent/Items/Lights/BaseLight.cs @@ -3,7 +3,7 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public abstract partial class BaseLight : Item { public static readonly bool Burnout = false; @@ -16,11 +16,10 @@ public abstract partial class BaseLight : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _protected; - [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeTimer))] private Timer _burnTimer; - [DeserializeTimerField(4)] private void DeserializeTimer(TimeSpan delay) { if (_burning && _duration != TimeSpan.Zero) @@ -29,6 +28,19 @@ public abstract partial class BaseLight : Item } } + private void MigrateFrom(V1Content content) + { + _burntOut = content.BurntOut; + _burning = content.Burning; + _duration = content.Duration; + _protected = content.Protected; + + if (content.BurnTimerDelay != TimeSpan.MinValue) + { + DeserializeTimer(content.BurnTimerDelay); + } + } + [Constructible] public BaseLight(int itemID) : base(itemID) { diff --git a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs index a52b1a12d..07df233e9 100644 --- a/Projects/UOContent/Items/Misc/CommunicationCrystals.cs +++ b/Projects/UOContent/Items/Misc/CommunicationCrystals.cs @@ -275,8 +275,16 @@ public partial class BroadcastCrystal : Item [SerializationGenerator(0)] public partial class ReceiverCrystal : Item { + [SerializableField(0, fieldChanged: nameof(OnSenderChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private BroadcastCrystal _sender; + private void OnSenderChanged(BroadcastCrystal oldValue, BroadcastCrystal newValue) + { + oldValue?.RemoveReceiver(this); + newValue?.AddReceiver(this); + } + [Constructible] public ReceiverCrystal() : base(0x1ED0) => Light = LightType.Circle150; @@ -297,20 +305,6 @@ public partial class ReceiverCrystal : Item } } - [SerializableProperty(0, useField: nameof(_sender))] - [CommandProperty(AccessLevel.GameMaster)] - public BroadcastCrystal Sender - { - get => _sender; - set - { - _sender?.RemoveReceiver(this); - _sender = value; - value?.AddReceiver(this); - this.MarkDirty(); - } - } - public override void GetProperties(IPropertyList list) { base.GetProperties(list); diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs new file mode 100644 index 000000000..bc9ce047e --- /dev/null +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.Migrations.cs @@ -0,0 +1,179 @@ +using System; + +namespace Server.Items; + +public partial class Corpse +{ + // Decay timer and TimeOfDeath moved from delta time to anchored time + private void MigrateFrom(V18Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + + // Decay timer moved from [TimerDrift]/[DeserializeTimerField] to [DeserializeTimer] + private void MigrateFrom(V17Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + _hairItemId = content.HairItemId; + _hairHue = content.HairHue; + _facialHairItemId = content.FacialHairItemId; + _facialHairHue = content.FacialHairHue; + + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + + // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) + private void MigrateFrom(V16Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Folded Murderer bool field into CorpseFlag.Murderer + private void MigrateFrom(V15Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Murderer) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Replaced int Kills snapshot with bool Murderer snapshot + private void MigrateFrom(V14Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + if (content.Hair != null) + { + _hairItemId = content.Hair.ItemId; + _hairHue = content.Hair.Hue; + } + + if (content.FacialHair != null) + { + _facialHairItemId = content.FacialHair.ItemId; + _facialHairHue = content.FacialHair.Hue; + } + } + + // Added corpse hair and corpse facial hair + private void MigrateFrom(V13Content content) + { + _restoreEquip = content.RestoreEquip; + _flags = content.Flags; + if (content.Kills >= 5) + { + _flags |= CorpseFlag.Murderer; + } + _timeOfDeath = content.TimeOfDeath; + _restoreTable = content.RestoreTable; + _decayTimer = new InternalTimer(this, content.DecayTimerDelay); + _decayTimer.Start(); + _looters = content.Looters; + _killer = content.Killer; + _aggressors = content.Aggressors; + _owner = content.Owner; + _corpseName = content.CorpseName; + _accessLevel = content.AccessLevel; + _guild = content.Guild; + _equipItems = content.EquipItems; + } +} diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs index 1ee04170c..04e9806c9 100644 --- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs @@ -86,7 +86,7 @@ public enum CorpseFlag OwnerWasAnimatedDead = 0x00000800 } -[SerializationGenerator(17, false)] +[SerializationGenerator(19, false)] public partial class Corpse : Container, ICarvable { public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0); @@ -106,7 +106,7 @@ public partial class Corpse : Container, ICarvable [SerializableField(1)] private CorpseFlag _flags; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _timeOfDeath; @@ -114,11 +114,10 @@ public partial class Corpse : Container, ICarvable [SerializableField(3, getter: "private", setter: "private")] private Dictionary _restoreTable; - [TimerDrift] [SerializableField(4, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(4)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); [SerializableField(5, setter: "private")] @@ -318,127 +317,6 @@ public partial class Corpse : Container, ICarvable DevourCorpse(); } - // Decomposed VirtualHairInfo into discrete int fields (hair/facial hair item id + hue) - private void MigrateFrom(V16Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Folded Murderer bool field into CorpseFlag.Murderer - private void MigrateFrom(V15Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Murderer) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Replaced int Kills snapshot with bool Murderer snapshot - private void MigrateFrom(V14Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - if (content.Hair != null) - { - _hairItemId = content.Hair.ItemId; - _hairHue = content.Hair.Hue; - } - - if (content.FacialHair != null) - { - _facialHairItemId = content.FacialHair.ItemId; - _facialHairHue = content.FacialHair.Hue; - } - } - - // Added corpse hair and corpse facial hair - private void MigrateFrom(V13Content content) - { - _restoreEquip = content.RestoreEquip; - _flags = content.Flags; - if (content.Kills >= 5) - { - _flags |= CorpseFlag.Murderer; - } - _timeOfDeath = content.TimeOfDeath; - _restoreTable = content.RestoreTable; - _decayTimer = new InternalTimer(this, content.DecayTimerDelay); - _decayTimer.Start(); - _looters = content.Looters; - _killer = content.Killer; - _aggressors = content.Aggressors; - _owner = content.Owner; - _corpseName = content.CorpseName; - _accessLevel = content.AccessLevel; - _guild = content.Guild; - _equipItems = content.EquipItems; - } - [CommandProperty(AccessLevel.GameMaster)] public virtual bool InstancedCorpse => Core.SE && Core.Now < TimeOfDeath + InstancedCorpseTime; diff --git a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs index ead812e2f..17267a6cc 100644 --- a/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs +++ b/Projects/UOContent/Items/Misc/Corpses/DecayedCorpse.cs @@ -3,18 +3,25 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public partial class DecayedCorpse : Container { private static TimeSpan _defaultDecayTime = TimeSpan.FromMinutes(7.0); - [TimerDrift] [SerializableField(0, getter: "private", setter: "private")] + [DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; - [DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) => BeginDecay(delay); + private void MigrateFrom(V2Content content) + { + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } + } + public DecayedCorpse(string name) : base(Utility.Random(0xECA, 9)) { Movable = false; diff --git a/Projects/UOContent/Items/Misc/MorphItem.cs b/Projects/UOContent/Items/Misc/MorphItem.cs index c5dd9c0de..7e5a74d8b 100644 --- a/Projects/UOContent/Items/Misc/MorphItem.cs +++ b/Projects/UOContent/Items/Misc/MorphItem.cs @@ -30,20 +30,24 @@ public partial class MorphItem : Item _outsideRange = outRange; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int OutsideRange + [SerializableField(0, allowFieldChange: nameof(AllowOutsideRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _outsideRange; + + private bool AllowOutsideRangeChange(ref int value) { - get => _outsideRange; - set => _outsideRange = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public int InsideRange + [SerializableField(3, allowFieldChange: nameof(AllowInsideRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _insideRange; + + private bool AllowInsideRangeChange(ref int value) { - get => _insideRange; - set => _insideRange = Math.Clamp(value, 0, 18); + value = Math.Clamp(value, 0, 18); + return true; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Misc/ProjectedItem.cs b/Projects/UOContent/Items/Misc/ProjectedItem.cs index e86025613..03e93d0ea 100644 --- a/Projects/UOContent/Items/Misc/ProjectedItem.cs +++ b/Projects/UOContent/Items/Misc/ProjectedItem.cs @@ -126,20 +126,14 @@ public partial class ProjectedItem : Item private static void OnTick() { - using var queue = PooledRefQueue.Create(); foreach (var item in _active) { if (!item.SendEffect()) { - queue.Enqueue(item); + _active.Remove(item); } } - while (queue.Count > 0) - { - _active.Remove(queue.Dequeue() as ProjectedItem); - } - if (_active.Count == 0) { _timer?.Stop(); diff --git a/Projects/UOContent/Items/Misc/WarningItem.cs b/Projects/UOContent/Items/Misc/WarningItem.cs index 5d0b491a4..3779e4900 100644 --- a/Projects/UOContent/Items/Misc/WarningItem.cs +++ b/Projects/UOContent/Items/Misc/WarningItem.cs @@ -14,8 +14,16 @@ public partial class WarningItem : Item private TextDefinition _warningMessage; // Field 1 + [SerializableField(1, allowFieldChange: nameof(AllowRangeChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] private int _range; + private bool AllowRangeChange(ref int value) + { + value = Math.Min(value, 18); + return true; + } + [SerializableField(2)] private TimeSpan _resetDelay; @@ -39,18 +47,6 @@ public partial class WarningItem : Item _range = Math.Min(range, 18); } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1, useField: nameof(_range))] - public int Range - { - get => _range; - set - { - _range = Math.Min(value, 18); - this.MarkDirty(); - } - } - public virtual bool OnlyToTriggerer => false; public virtual int NeighborRange => 5; diff --git a/Projects/UOContent/Items/Quivers/BaseQuiver.cs b/Projects/UOContent/Items/Quivers/BaseQuiver.cs index 427871f16..70824c9f6 100644 --- a/Projects/UOContent/Items/Quivers/BaseQuiver.cs +++ b/Projects/UOContent/Items/Quivers/BaseQuiver.cs @@ -11,64 +11,62 @@ public partial class BaseQuiver : Container, ICraftable, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAosAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] private bool ShouldSerializeAosAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [InvalidateProperties] [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLowerAmmoCost))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _lowerAmmoCost; - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeLowerAmmoCost() => _lowerAmmoCost != 0; [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeWeightReduction))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _weightReduction; - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeWeightReduction() => _weightReduction != 0; [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeDamageIncrease))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _damageIncrease; - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeDamageIncrease() => _damageIncrease != 0; [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster)] private ClothingQuality _quality; - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeQuality() => _quality != ClothingQuality.Regular; - [SerializableFieldDefault(5)] private ClothingQuality QualityDefaultValue() => ClothingQuality.Regular; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeCapacity))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _capacity; - [SerializableFieldSaveFlag(6)] private bool ShouldSerializeCapacity() => _capacity != 0; public BaseQuiver(int itemID = 0x2FB7) : base(itemID) diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs index ce772da40..8170b286d 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ingots.cs @@ -16,22 +16,14 @@ public abstract partial class BaseIngot : Item, ICommodity public override double DefaultWeight => 0.1; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs index 3116d7136..6ae0c71c3 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Ore.cs @@ -17,22 +17,14 @@ public abstract partial class BaseOre : Item _resource = resource; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs index fa63d902c..277ed569d 100644 --- a/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs +++ b/Projects/UOContent/Items/Resources/Blacksmithing/Scales.cs @@ -14,22 +14,14 @@ public abstract partial class BaseScales : Item, ICommodity _resource = resource; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber => 1053139; // dragon scales diff --git a/Projects/UOContent/Items/Resources/Masonry/Granite.cs b/Projects/UOContent/Items/Resources/Masonry/Granite.cs index ae4f215d7..089181ebe 100644 --- a/Projects/UOContent/Items/Resources/Masonry/Granite.cs +++ b/Projects/UOContent/Items/Resources/Masonry/Granite.cs @@ -15,22 +15,14 @@ public abstract partial class BaseGranite : Item public override double DefaultWeight => Core.ML ? 1.0 : 10.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber => 1044607; // high quality granite diff --git a/Projects/UOContent/Items/Resources/Tailor/Hides.cs b/Projects/UOContent/Items/Resources/Tailor/Hides.cs index df6423fb2..d64eb2f0b 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Hides.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Hides.cs @@ -15,22 +15,14 @@ public abstract partial class BaseHides : Item, ICommodity public override double DefaultWeight => 5.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs index 173e2dae0..afb757e1f 100644 --- a/Projects/UOContent/Items/Resources/Tailor/Leathers.cs +++ b/Projects/UOContent/Items/Resources/Tailor/Leathers.cs @@ -15,22 +15,14 @@ public abstract partial class BaseLeather : Item, ICommodity public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public override int LabelNumber diff --git a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs index 9ff0d8386..53c8077f1 100644 --- a/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs +++ b/Projects/UOContent/Items/Skill Items/Carpenter Items/Board.cs @@ -21,22 +21,14 @@ public partial class Board : Item, ICommodity Hue = CraftResources.GetHue(resource); } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } int ICommodity.DescriptionNumber diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs index 5d6062099..bf626c2fd 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/MessageInABottle.cs @@ -25,16 +25,14 @@ public partial class MessageInABottle : Item public override int LabelNumber => 1041080; // a message in a bottle - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Level + [SerializableField(0, allowFieldChange: nameof(AllowLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _level; + + private bool AllowLevelChange(ref int value) { - get => _level; - set - { - _level = Math.Max(1, Math.Min(value, 4)); - this.MarkDirty(); - } + value = Math.Max(1, Math.Min(value, 4)); + return true; } public static int GetRandomLevel() diff --git a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs index f8450e63f..5374f8716 100644 --- a/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs +++ b/Projects/UOContent/Items/Skill Items/Fishing/Misc/SOS.cs @@ -103,18 +103,20 @@ public partial class SOS : Item [CommandProperty(AccessLevel.GameMaster)] public bool IsAncient => _level >= 4; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Level + [SerializableField(0, fieldChanged: nameof(OnLevelChanged), allowFieldChange: nameof(AllowLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _level; + + private bool AllowLevelChange(ref int value) { - get => _level; - set - { - _level = Math.Max(1, Math.Min(value, 4)); - UpdateHue(); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Max(1, Math.Min(value, 4)); + return true; + } + + private void OnLevelChanged(int oldValue, int newValue) + { + UpdateHue(); } public void UpdateHue() => Hue = IsAncient ? 0x481 : 0; diff --git a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs index 2873957d5..2a06f535a 100644 --- a/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs +++ b/Projects/UOContent/Items/Skill Items/Lumberjack/Log.cs @@ -28,22 +28,14 @@ public partial class Log : Item, ICommodity, IAxe public override double DefaultWeight => 2.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource - { - get => _resource; - set - { - if (_resource != value) - { - _resource = value; - Hue = CraftResources.GetHue(value); + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; - InvalidateProperties(); - this.MarkDirty(); - } - } + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) + { + Hue = CraftResources.GetHue(newValue); } public virtual bool Axe(Mobile from, BaseAxe axe) => TryCreateBoards(from, 0, new Board()); diff --git a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs index 68b6f324a..14db7ac1e 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Misc/RecallRune.cs @@ -47,36 +47,24 @@ public partial class RecallRune : Item } } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - public bool Marked + [SerializableField(2, fieldChanged: nameof(OnMarkedChanged))] + [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _marked; + + private void OnMarkedChanged(bool oldValue, bool newValue) { - get => _marked; - set - { - if (_marked != value) - { - _marked = value; - CalculateHue(); - InvalidateProperties(); - } - } + CalculateHue(); } - [SerializableProperty(4)] - [CommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] - public Map TargetMap + [SerializableField(4, fieldChanged: nameof(OnTargetMapChanged))] + [SerializedCommandProperty(AccessLevel.Counselor, AccessLevel.GameMaster)] + [InvalidateProperties] + private Map _targetMap; + + private void OnTargetMapChanged(Map oldValue, Map newValue) { - get => _targetMap; - set - { - if (_targetMap != value) - { - _targetMap = value; - CalculateHue(); - InvalidateProperties(); - } - } + CalculateHue(); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs index 0a5a0f308..f9ecdfa22 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Runebook.cs @@ -371,27 +371,27 @@ public partial class RunebookEntry private Runebook _runebook; [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeHouse))] private BaseHouse _house; - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeHouse() => _house?.Deleted == false; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeLocation))] private Point3D _location; - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeLocation() => _house?.Deleted != false; [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeMap))] private Map _map; - [SerializableFieldSaveFlag(2)] public bool ShouldSerializeMap() => _house?.Deleted != false; [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeDesc))] private string _description; - [SerializableFieldSaveFlag(3)] public bool ShouldSerializeDesc() => _house?.Deleted != false; public RunebookEntry( diff --git a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs index f1ced7308..9447fddfa 100644 --- a/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs +++ b/Projects/UOContent/Items/Skill Items/Magical/Spellbook.cs @@ -133,28 +133,19 @@ public partial class Spellbook : Item, ICraftable, ISlayer, IAosItem public virtual int BookOffset => 0; public virtual int BookCount => 64; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(7)] - public ulong Content + [SerializableField(7, fieldChanged: nameof(OnContentChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private ulong _content; + + private void OnContentChanged(ulong oldValue, ulong newValue) { - get => _content; - set + // This assignment will mark it as dirty + SpellCount = 0; + while (newValue > 0) { - if (_content != value) - { - _content = value; - - // This assignment will mark it as dirty - SpellCount = 0; - - while (value > 0) - { - _spellCount += (int)(value & 0x1); - value >>= 1; - } - - InvalidateProperties(); - } + _spellCount += (int)(newValue & 0x1); + newValue >>= 1; } } diff --git a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs index dbb8df68c..e4d486d10 100644 --- a/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs +++ b/Projects/UOContent/Items/Skill Items/Misc/RepairDeed.cs @@ -62,17 +62,15 @@ public partial class RepairDeed : Item public override bool DisplayLootType => false; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(1)] - public double SkillLevel + [SerializableField(1, allowFieldChange: nameof(AllowSkillLevelChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private double _skillLevel; + + private bool AllowSkillLevelChange(ref double value) { - get => _skillLevel; - set - { - _skillLevel = Math.Clamp(value, 0, 120.0); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, 120.0); + return true; } public override void AddNameProperty(IPropertyList list) diff --git a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs index b01416211..f7716a4a1 100644 --- a/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs +++ b/Projects/UOContent/Items/Skill Items/Musical Instruments/BaseInstrument.cs @@ -74,16 +74,13 @@ public abstract partial class BaseInstrument : Item, ICraftable, ISlayer } } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime LastReplenished + [SerializableField(1, fieldChanged: nameof(OnLastReplenishedChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _lastReplenished; + + private void OnLastReplenishedChanged(DateTime oldValue, DateTime newValue) { - get => _lastReplenished; - set - { - _lastReplenished = value; - CheckReplenishUses(); - } + CheckReplenishUses(); } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs index 6b557b7be..e35cca471 100644 --- a/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs +++ b/Projects/UOContent/Items/Skill Items/Tailor Items/Dyetubs/DyeTub.cs @@ -41,20 +41,13 @@ namespace Server.Items public virtual bool AllowDyables => true; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int DyedHue - { - get => _dyedHue; - set - { - if (_redyable) - { - _dyedHue = value; - Hue = value; - } - } - } + [SerializableField(2, allowFieldChange: nameof(AllowDyedHueChange), fieldChanged: nameof(OnDyedHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _dyedHue; + + private bool AllowDyedHueChange(ref int value) => _redyable; + + private void OnDyedHueChanged(int oldValue, int newValue) => Hue = newValue; // Three metallic tubs now. public virtual bool MetallicHues => false; diff --git a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs index 6010ec376..595b6b11d 100644 --- a/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs +++ b/Projects/UOContent/Items/Skill Items/Tools/BaseRunicTool.cs @@ -60,17 +60,14 @@ public abstract partial class BaseRunicTool : BaseTool public BaseRunicTool(CraftResource resource, int uses, int itemID) : base(uses, itemID) => _resource = resource; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public CraftResource Resource + [SerializableField(0, fieldChanged: nameof(OnResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _resource; + + private void OnResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _resource; - set - { - _resource = value; - Hue = CraftResources.GetHue(_resource); - InvalidateProperties(); - } + Hue = CraftResources.GetHue(_resource); } private void Deserialize(IGenericReader reader, int version) diff --git a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs index 3bdfd00fa..404fc9012 100644 --- a/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs +++ b/Projects/UOContent/Items/Special/8th Anniversary Items/FountainOfLife.cs @@ -31,6 +31,7 @@ public partial class FountainOfLife : BaseAddonContainer public const int MaxCharges = 10; [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeTimer), wallClock: true)] private Timer _timer; [Constructible] @@ -39,7 +40,6 @@ public partial class FountainOfLife : BaseAddonContainer _charges = charges; } - [DeserializeTimerField(1)] private void DeserializeTimer(TimeSpan delay) { _timer = Timer.DelayCall(Utility.Max(delay, TimeSpan.Zero), RechargeTime, Recharge); @@ -54,17 +54,15 @@ public partial class FountainOfLife : BaseAddonContainer public override int DefaultDropSound => 66; public override int DefaultMaxItems => 125; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Min(value, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Min(value, MaxCharges); + return true; } public override bool OnDragLift(Mobile from) => false; @@ -183,16 +181,14 @@ public partial class FountainOfLifeDeed : BaseAddonContainerDeed public override int LabelNumber => 1075197; // Fountain of Life public override BaseAddonContainer Addon => new FountainOfLife(_charges); - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Min(value, FountainOfLife.MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Min(value, FountainOfLife.MaxCharges); + return true; } } diff --git a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs index 6a7e5af6f..fabb756d8 100644 --- a/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs +++ b/Projects/UOContent/Items/Special/Heritage Items/FruitTrees.cs @@ -14,12 +14,14 @@ public abstract partial class BaseFruitTreeAddon : BaseAddon public abstract override BaseAddonDeed Deed { get; } public abstract Item Fruit { get; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Fruits + [SerializableField(0, allowFieldChange: nameof(AllowFruitsChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _fruits; + + private bool AllowFruitsChange(ref int value) { - get => _fruits; - set => _fruits = Math.Max(value, 0); + value = Math.Max(value, 0); + return true; } public override void OnComponentUsed(AddonComponent c, Mobile from) diff --git a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs index c24985f9f..59e2b8277 100644 --- a/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs +++ b/Projects/UOContent/Items/Special/House Raffle/HouseRaffleStone.cs @@ -146,34 +146,24 @@ public partial class HouseRaffleStone : Item } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public Rectangle2D PlotBounds - { - get => _plotBounds; - set - { - _plotBounds = value; + [SerializableField(3, fieldChanged: nameof(OnPlotBoundsChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private Rectangle2D _plotBounds; - InvalidateRegion(); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnPlotBoundsChanged(Rectangle2D oldValue, Rectangle2D newValue) + { + InvalidateRegion(); } - [SerializableProperty(4)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public Map PlotFacet - { - get => _plotFacet; - set - { - _plotFacet = value; + [SerializableField(4, fieldChanged: nameof(OnPlotFacetChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private Map _plotFacet; - InvalidateRegion(); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnPlotFacetChanged(Map oldValue, Map newValue) + { + InvalidateRegion(); } [CommandProperty(AccessLevel.GameMaster)] @@ -190,17 +180,15 @@ public partial class HouseRaffleStone : Item } } - [SerializableProperty(6)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] - public int TicketPrice + [SerializableField(6, allowFieldChange: nameof(AllowTicketPriceChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Seer)] + [InvalidateProperties] + private int _ticketPrice; + + private bool AllowTicketPriceChange(ref int value) { - get => _ticketPrice; - set - { - _ticketPrice = Math.Max(0, value); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Max(0, value); + return true; } public override string DefaultName => "a house raffle stone"; diff --git a/Projects/UOContent/Items/Special/MonsterStatuette.cs b/Projects/UOContent/Items/Special/MonsterStatuette.cs index 8d49dd0e4..fd26a0060 100644 --- a/Projects/UOContent/Items/Special/MonsterStatuette.cs +++ b/Projects/UOContent/Items/Special/MonsterStatuette.cs @@ -162,21 +162,15 @@ public partial class MonsterStatuette : Item, IRewardItem, IGumpToggleItem _ => fallback }; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public MonsterStatuetteType Type + [SerializableField(0, fieldChanged: nameof(OnTypeChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private MonsterStatuetteType _type; + + private void OnTypeChanged(MonsterStatuetteType oldValue, MonsterStatuetteType newValue) { - get => _type; - set - { - _type = value; - ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; - - Hue = GetStatuetteHue(_type, Hue); - - InvalidateProperties(); - this.MarkDirty(); - } + ItemID = MonsterStatuetteInfo.GetInfo(_type).ItemID; + Hue = GetStatuetteHue(_type, Hue); } public override int LabelNumber => MonsterStatuetteInfo.GetInfo(_type).LabelNumber; diff --git a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs index f2772b342..9441fd5c7 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs @@ -36,50 +36,41 @@ public partial class BagOfSending : Item, TranslocationItem public override int LabelNumber => 1054104; // a bag of sending - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public BagOfSendingHue BagOfSendingHue - { - get => _bagOfSendingHue; - set - { - _bagOfSendingHue = value; + [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private BagOfSendingHue _bagOfSendingHue; - Hue = value switch - { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; - this.MarkDirty(); - } + private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) + { + Hue = newValue switch + { + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(2, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs index a76833e30..070a802b1 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BallOfSummoning.cs @@ -29,30 +29,26 @@ public partial class BallOfSummoning : Item, TranslocationItem public override double DefaultWeight => 10.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } [SerializableProperty(2)] diff --git a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs index 2f82791c2..1d508c4ac 100644 --- a/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs +++ b/Projects/UOContent/Items/Special/Solen Items/BraceletOfBinding.cs @@ -27,30 +27,26 @@ public partial class BraceletOfBinding : BaseBracelet, TranslocationItem public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public int Recharges + [SerializableField(0, allowFieldChange: nameof(AllowRechargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _recharges; + + private bool AllowRechargesChange(ref int value) { - get => _recharges; - set - { - _recharges = Math.Clamp(value, 0, MaxRecharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxRecharges); + return true; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) { - get => _charges; - set - { - _charges = Math.Clamp(value, 0, MaxCharges); - InvalidateProperties(); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, MaxCharges); + return true; } [SerializableProperty(3)] diff --git a/Projects/UOContent/Items/Special/SoulStone.cs b/Projects/UOContent/Items/Special/SoulStone.cs index bcad4a749..d26f6792c 100644 --- a/Projects/UOContent/Items/Special/SoulStone.cs +++ b/Projects/UOContent/Items/Special/SoulStone.cs @@ -83,20 +83,14 @@ public partial class SoulStone : Item, ISecurable } } - [SerializableProperty(6)] - [CommandProperty(AccessLevel.GameMaster)] - public double SkillValue + [SerializableField(6, fieldChanged: nameof(OnSkillValueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private double _skillValue; + + private void OnSkillValueChanged(double oldValue, double newValue) { - get => _skillValue; - set - { - _skillValue = value; - - ItemID = IsEmpty ? _inactiveItemID : _activeItemID; - - InvalidateProperties(); - this.MarkDirty(); - } + ItemID = IsEmpty ? _inactiveItemID : _activeItemID; } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Items/Suits/BaseSuit.cs b/Projects/UOContent/Items/Suits/BaseSuit.cs index 018c010a2..dd1eb00f6 100644 --- a/Projects/UOContent/Items/Suits/BaseSuit.cs +++ b/Projects/UOContent/Items/Suits/BaseSuit.cs @@ -17,20 +17,9 @@ public abstract partial class BaseSuit : Item public override double DefaultWeight => 1.0; - [SerializableProperty(0)] - public AccessLevel AccessLevel - { - get => _accessLevel; - set - { - var oldAccessLevel = _accessLevel; - _accessLevel = value; - InvalidateProperties(); - this.MarkDirty(); - - OnAccessLevelChanged(oldAccessLevel, _accessLevel); - } - } + [SerializableField(0, fieldChanged: nameof(OnAccessLevelChanged))] + [InvalidateProperties] + private AccessLevel _accessLevel; public virtual void OnAccessLevelChanged(AccessLevel oldAccessLevel, AccessLevel accessLevel) { diff --git a/Projects/UOContent/Items/Talismans/BaseTalisman.cs b/Projects/UOContent/Items/Talismans/BaseTalisman.cs index 632587523..b3f0c2ed4 100644 --- a/Projects/UOContent/Items/Talismans/BaseTalisman.cs +++ b/Projects/UOContent/Items/Talismans/BaseTalisman.cs @@ -128,136 +128,131 @@ public partial class BaseTalisman : Item, IAosItem [SerializedIgnoreDupe] [SerializableField(0, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(0)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(1, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(1)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(2)] + [SaveFlag(nameof(ShouldSerializeProtection), nameof(ProtectionDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _protection; - [SerializableFieldSaveFlag(2)] public bool ShouldSerializeProtection() => !_protection.IsEmpty; - [SerializableFieldDefault(2)] private TalismanAttribute ProtectionDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(3)] + [SaveFlag(nameof(ShouldSerializeKiller), nameof(KillerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _killer; - [SerializableFieldSaveFlag(3)] public bool ShouldSerializeKiller() => !_killer.IsEmpty; - [SerializableFieldDefault(3)] private TalismanAttribute KillerDefaultValue() => new(); [SerializedIgnoreDupe] [InvalidateProperties] [SerializableField(4)] + [SaveFlag(nameof(ShouldSerializeSummoner), nameof(SummonerDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private TalismanAttribute _summoner; - [SerializableFieldSaveFlag(4)] public bool ShouldSerializeSummoner() => !_summoner.IsEmpty; - [SerializableFieldDefault(4)] private TalismanAttribute SummonerDefaultValue() => new(); [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeRemoval))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanRemoval _removal; - [SerializableFieldSaveFlag(5)] public bool ShouldSerializeRemoval() => _removal != TalismanRemoval.None; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeSkill))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SkillName _skill; - [SerializableFieldSaveFlag(6)] public bool ShouldSerializeSkill() => (int)_skill != 0; [EncodedInt] [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializeSuccessBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _successBonus; - [SerializableFieldSaveFlag(7)] public bool ShouldSerializeSuccessBonus() => _successBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializeExceptionalBonus))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _exceptionalBonus; - [SerializableFieldSaveFlag(8)] public bool ShouldSerializeExceptionalBonus() => _exceptionalBonus != 0; [EncodedInt] [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeMaxCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxCharges; - [SerializableFieldSaveFlag(9)] public bool ShouldSerializeMaxCharges() => _maxCharges != 0; [EncodedInt] [InvalidateProperties] [SerializableField(11)] + [SaveFlag(nameof(ShouldSerializeMaxChargeTime))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxChargeTime; - [SerializableFieldSaveFlag(11)] public bool ShouldSerializeMaxChargeTime() => _maxChargeTime != 0; [EncodedInt] [InvalidateProperties] [SerializableField(12)] + [SaveFlag(nameof(ShouldSerializeChargeTime))] private int _chargeTime; - [SerializableFieldSaveFlag(12)] public bool ShouldSerializeChargeTime() => _chargeTime != 0; [InvalidateProperties] [SerializableField(13)] + [SaveFlag(nameof(ShouldSerializeBlessed))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _blessed; - [SerializableFieldSaveFlag(13)] public bool ShouldSerializeBlessed() => _blessed; [InvalidateProperties] [SerializableField(14)] + [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private TalismanSlayerName _slayer; - [SerializableFieldSaveFlag(14)] public bool ShouldSerializeSlayer() => _slayer != TalismanSlayerName.None; private BaseCreature _creature; @@ -284,26 +279,20 @@ public partial class BaseTalisman : Item, IAosItem public override int LabelNumber => 1071023; // Talisman public virtual bool ForceShowName => false; // used to override default summoner/removal name - [SerializableProperty(10)] - [CommandProperty(AccessLevel.GameMaster)] - public int Charges + [SerializableField(10, fieldChanged: nameof(OnChargesChanged))] + [SaveFlag(nameof(ShouldSerializeCharges))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private void OnChargesChanged(int oldValue, int newValue) { - get => _charges; - set + if (_chargeTime > 0) { - _charges = value; - - if (_chargeTime > 0) - { - StartTimer(); - } - - InvalidateProperties(); - this.MarkDirty(); + StartTimer(); } } - [SerializableFieldSaveFlag(10)] public bool ShouldSerializeCharges() => _charges != 0; public static void Configure() diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs new file mode 100644 index 000000000..3385598d9 --- /dev/null +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.Migrations.cs @@ -0,0 +1,311 @@ +using System; +using Server.Engines.Craft; + +namespace Server.Items; + +public partial class BaseWeapon +{ + // PlayerConstructed moved onto Item + private void MigrateFrom(V10Content content) + { + _damageLevel = content.DamageLevel ?? WeaponDamageLevel.Regular; + _accuracyLevel = content.AccuracyLevel ?? WeaponAccuracyLevel.Regular; + _durabilityLevel = content.DurabilityLevel ?? WeaponDurabilityLevel.Regular; + _quality = content.Quality ?? WeaponQuality.Regular; + _hitPoints = content.HitPoints ?? 0; + _maxHitPoints = content.MaxHitPoints ?? 0; + _slayer = content.Slayer ?? SlayerName.None; + _poison = content.Poison; + _poisonCharges = content.PoisonCharges ?? 0; + _crafter = content.Crafter; + _identified = content.Identified; + _strRequirement = content.StrRequirement ?? -1; + _dexRequirement = content.DexRequirement ?? -1; + _intRequirement = content.IntRequirement ?? -1; + _minDamage = content.MinDamage ?? -1; + _maxDamage = content.MaxDamage ?? -1; + _hitSound = content.HitSound ?? -1; + _missSound = content.MissSound ?? -1; + _speed = content.Speed ?? -1; + _maxRange = content.MaxRange ?? -1; + _skill = content.Skill ?? (SkillName)(-1); + _type = content.Type ?? (WeaponType)(-1); + _animation = content.Animation ?? (WeaponAnimation)(-1); + _resource = content.Resource ?? CraftResource.Iron; + _attributes = content.Attributes ?? AttributesDefaultValue(); + _weaponAttributes = content.WeaponAttributes ?? WeaponAttributesDefaultValue(); + PlayerConstructed = content.PlayerConstructed; + _skillBonuses = content.SkillBonuses ?? SkillBonusesDefaultValue(); + _slayer2 = content.Slayer2 ?? SlayerName.None; + _aosElementDamages = content.AosElementDamages ?? AosElementAttributesDefaultValue(); + _engravedText = content.EngravedText; + } + + // Version 9 (pre-codegen) + private void Deserialize(IGenericReader reader, int version) + { + var flags = (OldSaveFlag)reader.ReadInt(); + + if (GetSaveFlag(flags, OldSaveFlag.DamageLevel)) + { + _damageLevel = (WeaponDamageLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.AccuracyLevel)) + { + _accuracyLevel = (WeaponAccuracyLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.DurabilityLevel)) + { + _durabilityLevel = (WeaponDurabilityLevel)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Quality)) + { + _quality = (WeaponQuality)reader.ReadInt(); + } + else + { + _quality = WeaponQuality.Regular; + } + + if (GetSaveFlag(flags, OldSaveFlag.Hits)) + { + _hitPoints = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxHits)) + { + _maxHitPoints = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Slayer)) + { + _slayer = (SlayerName)reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Poison)) + { + _poison = reader.ReadPoison(); + } + + if (GetSaveFlag(flags, OldSaveFlag.PoisonCharges)) + { + _poisonCharges = reader.ReadInt(); + } + + if (GetSaveFlag(flags, OldSaveFlag.Crafter)) + { + Timer.DelayCall(crafter => _crafter = crafter?.RawName, reader.ReadEntity()); + } + + if (GetSaveFlag(flags, OldSaveFlag.Identified)) + { + _identified = version >= 6 || reader.ReadBool(); + } + + if (GetSaveFlag(flags, OldSaveFlag.StrReq)) + { + _strRequirement = reader.ReadInt(); + } + else + { + _strRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.DexReq)) + { + _dexRequirement = reader.ReadInt(); + } + else + { + _dexRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.IntReq)) + { + _intRequirement = reader.ReadInt(); + } + else + { + _intRequirement = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MinDamage)) + { + _minDamage = reader.ReadInt(); + } + else + { + _minDamage = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxDamage)) + { + _maxDamage = reader.ReadInt(); + } + else + { + _maxDamage = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.HitSound)) + { + _hitSound = reader.ReadInt(); + } + else + { + _hitSound = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MissSound)) + { + _missSound = reader.ReadInt(); + } + else + { + _missSound = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.Speed)) + { + if (version < 9) + { + _speed = reader.ReadInt(); + } + else + { + _speed = reader.ReadFloat(); + } + } + else + { + _speed = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.MaxRange)) + { + _maxRange = reader.ReadInt(); + } + else + { + _maxRange = -1; + } + + if (GetSaveFlag(flags, OldSaveFlag.Skill)) + { + _skill = (SkillName)reader.ReadInt(); + } + else + { + _skill = (SkillName)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Type)) + { + _type = (WeaponType)reader.ReadInt(); + } + else + { + _type = (WeaponType)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Animation)) + { + _animation = (WeaponAnimation)reader.ReadInt(); + } + else + { + _animation = (WeaponAnimation)(-1); + } + + if (GetSaveFlag(flags, OldSaveFlag.Resource)) + { + _resource = (CraftResource)reader.ReadInt(); + } + else + { + _resource = CraftResource.Iron; + } + + Attributes = new AosAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.Attributes)) + { + Attributes.Deserialize(reader); + } + + WeaponAttributes = new AosWeaponAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.WeaponAttributes)) + { + WeaponAttributes.Deserialize(reader); + } + + PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); + + SkillBonuses = new AosSkillBonuses(this); + + if (GetSaveFlag(flags, OldSaveFlag.SkillBonuses)) + { + SkillBonuses.Deserialize(reader); + } + + if (GetSaveFlag(flags, OldSaveFlag.Slayer2)) + { + _slayer2 = (SlayerName)reader.ReadInt(); + } + + AosElementDamages = new AosElementAttributes(this); + + if (GetSaveFlag(flags, OldSaveFlag.ElementalDamages)) + { + AosElementDamages.Deserialize(reader); + } + + if (GetSaveFlag(flags, OldSaveFlag.EngravedText)) + { + _engravedText = reader.ReadString(); + } + } + + private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; + + [Flags] + private enum OldSaveFlag + { + None = 0x00000000, + DamageLevel = 0x00000001, + AccuracyLevel = 0x00000002, + DurabilityLevel = 0x00000004, + Quality = 0x00000008, + Hits = 0x00000010, + MaxHits = 0x00000020, + Slayer = 0x00000040, + Poison = 0x00000080, + PoisonCharges = 0x00000100, + Crafter = 0x00000200, + Identified = 0x00000400, + StrReq = 0x00000800, + DexReq = 0x00001000, + IntReq = 0x00002000, + MinDamage = 0x00004000, + MaxDamage = 0x00008000, + HitSound = 0x00010000, + MissSound = 0x00020000, + Speed = 0x00040000, + MaxRange = 0x00080000, + Skill = 0x00100000, + Type = 0x00200000, + Animation = 0x00400000, + Resource = 0x00800000, + Attributes = 0x01000000, + WeaponAttributes = 0x02000000, + PlayerConstructed = 0x04000000, + SkillBonuses = 0x08000000, + Slayer2 = 0x10000000, + ElementalDamages = 0x20000000, + EngravedText = 0x40000000 + } +} diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index b28f0d371..d48824670 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -27,7 +27,7 @@ public interface ISlayer SlayerName Slayer2 { get; set; } } -[SerializationGenerator(10, false)] +[SerializationGenerator(11, false)] public abstract partial class BaseWeapon : Item, IWeapon, IFactionItem, ICraftable, ISlayer, IDurability, IAosItem, IIdentifiable { @@ -56,138 +56,126 @@ public abstract partial class BaseWeapon [InvalidateProperties] [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeDamageLevel))] [SerializedCommandProperty(AccessLevel.GameMaster)] private WeaponDamageLevel _damageLevel; - [SerializableFieldSaveFlag(0)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeDamageLevel() => _damageLevel != WeaponDamageLevel.Regular; [InvalidateProperties] [SerializableField(5)] + [SaveFlag(nameof(ShouldSerializeMaxHitPoints))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _maxHitPoints; - [SerializableFieldSaveFlag(5)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeMaxHitPoints() => _maxHitPoints != 0; [InvalidateProperties] [SerializableField(6)] + [SaveFlag(nameof(ShouldSerializeSlayer))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer; - [SerializableFieldSaveFlag(6)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer() => _slayer != SlayerName.None; [InvalidateProperties] [SerializableField(7)] + [SaveFlag(nameof(ShouldSerializePoison))] [SerializedCommandProperty(AccessLevel.GameMaster)] private Poison _poison; - [SerializableFieldSaveFlag(7)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoison() => _poison != null; [InvalidateProperties] [SerializableField(8)] + [SaveFlag(nameof(ShouldSerializePoisonCharges))] [SerializedCommandProperty(AccessLevel.GameMaster)] private int _poisonCharges; - [SerializableFieldSaveFlag(8)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializePoisonCharges() => _poisonCharges > 0; [InvalidateProperties] [SerializableField(9)] + [SaveFlag(nameof(ShouldSerializeCrafter))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _crafter; - [SerializableFieldSaveFlag(9)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeCrafter() => !string.IsNullOrEmpty(_crafter); [InvalidateProperties] [SerializableField(10)] + [SaveFlag(nameof(ShouldSerializeIdentified))] [SerializedCommandProperty(AccessLevel.GameMaster)] private bool _identified; - [SerializableFieldSaveFlag(10)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeIdentified() => _identified; [SerializedIgnoreDupe] [SerializableField(24, setter: "private")] + [SaveFlag(nameof(ShouldSerializeAttributes), nameof(AttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosAttributes _attributes; - [SerializableFieldSaveFlag(24)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeAttributes() => !_attributes.IsEmpty; - [SerializableFieldDefault(24)] private AosAttributes AttributesDefaultValue() => new(this); [SerializedIgnoreDupe] [SerializableField(25, setter: "private")] + [SaveFlag(nameof(ShouldSerializeWeaponAttributes), nameof(WeaponAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosWeaponAttributes _weaponAttributes; - [SerializableFieldSaveFlag(25)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeWeaponAttributes() => !_weaponAttributes.IsEmpty; - [SerializableFieldDefault(25)] private AosWeaponAttributes WeaponAttributesDefaultValue() => new(this); - [SerializableField(26)] - [SerializedCommandProperty(AccessLevel.GameMaster)] - private bool _playerConstructed; - - [SerializableFieldSaveFlag(26)] - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private bool ShouldSerializePlayerConstructed() => _playerConstructed; - [SerializedIgnoreDupe] - [SerializableField(27, setter: "private")] + [SerializableField(26, setter: "private")] + [SaveFlag(nameof(ShouldSerializeSkillBonuses), nameof(SkillBonusesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosSkillBonuses _skillBonuses; - [SerializableFieldSaveFlag(27)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSkillBonuses() => !_skillBonuses.IsEmpty; - [SerializableFieldDefault(27)] private AosSkillBonuses SkillBonusesDefaultValue() => new(this); [InvalidateProperties] - [SerializableField(28)] + [SerializableField(27)] + [SaveFlag(nameof(ShouldSerializeSlayer2))] [SerializedCommandProperty(AccessLevel.GameMaster)] private SlayerName _slayer2; - [SerializableFieldSaveFlag(28)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeSlayer2() => _slayer2 != SlayerName.None; [SerializedIgnoreDupe] - [SerializableField(29, setter: "private")] + [SerializableField(28, setter: "private")] + [SaveFlag(nameof(ShouldSerializeElementAttributes), nameof(AosElementAttributesDefaultValue))] [SerializedCommandProperty(AccessLevel.GameMaster, canModify: true)] private AosElementAttributes _aosElementDamages; - [SerializableFieldSaveFlag(29)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeElementAttributes() => !_aosElementDamages.IsEmpty; - [SerializableFieldDefault(29)] private AosElementAttributes AosElementAttributesDefaultValue() => new(this); [InvalidateProperties] - [SerializableField(30)] + [SerializableField(29)] + [SaveFlag(nameof(ShouldSerializeEngravedText))] [SerializedCommandProperty(AccessLevel.GameMaster)] private string _engravedText; - [SerializableFieldSaveFlag(30)] [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool ShouldSerializeEngravedText() => !string.IsNullOrEmpty(_engravedText); @@ -294,6 +282,7 @@ public abstract partial class BaseWeapon public bool Consecrated { get; set; } [SerializableProperty(1)] + [SaveFlag(nameof(ShouldSerializeWeaponAccuracy))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAccuracyLevel AccuracyLevel { @@ -329,10 +318,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(1)] private bool ShouldSerializeWeaponAccuracy() => _accuracyLevel != WeaponAccuracyLevel.Regular; [SerializableProperty(2)] + [SaveFlag(nameof(ShouldSerializeDurabilityLevel))] [CommandProperty(AccessLevel.GameMaster)] public WeaponDurabilityLevel DurabilityLevel { @@ -347,10 +336,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(2)] private bool ShouldSerializeDurabilityLevel() => _durabilityLevel != WeaponDurabilityLevel.Regular; [SerializableProperty(3)] + [SaveFlag(nameof(ShouldSerializeQuality), nameof(QualityDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponQuality Quality { @@ -365,13 +354,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(3)] private bool ShouldSerializeQuality() => _quality != WeaponQuality.Regular; - [SerializableFieldDefault(3)] private WeaponQuality QualityDefaultValue() => WeaponQuality.Regular; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeHitPoints))] [CommandProperty(AccessLevel.GameMaster)] public int HitPoints { @@ -395,10 +383,10 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeHitPoints() => _hitPoints > 0; [SerializableProperty(11)] + [SaveFlag(nameof(ShouldSerializeStrReq), nameof(StrReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int StrRequirement { @@ -411,13 +399,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(11)] private bool ShouldSerializeStrReq() => _strRequirement != -1; - [SerializableFieldDefault(11)] private int StrReqDefaultValue() => -1; [SerializableProperty(12)] + [SaveFlag(nameof(ShouldSerializeDexReq), nameof(DexReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int DexRequirement { @@ -430,13 +417,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(12)] private bool ShouldSerializeDexReq() => _dexRequirement != -1; - [SerializableFieldDefault(12)] private int DexReqDefaultValue() => -1; [SerializableProperty(13)] + [SaveFlag(nameof(ShouldSerializeIntReq), nameof(IntReqDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int IntRequirement { @@ -449,13 +435,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(13)] private bool ShouldSerializeIntReq() => _intRequirement != -1; - [SerializableFieldDefault(13)] private int IntReqDefaultValue() => -1; [SerializableProperty(14)] + [SaveFlag(nameof(ShouldSerializeMinDamage), nameof(MinDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MinDamage { @@ -468,13 +453,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(14)] private bool ShouldSerializeMinDamage() => _minDamage != -1; - [SerializableFieldDefault(14)] private int MinDamageDefaultValue() => -1; [SerializableProperty(15)] + [SaveFlag(nameof(ShouldSerializeMaxDamage), nameof(MaxDamageDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxDamage { @@ -487,13 +471,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(15)] private bool ShouldSerializeMaxDamage() => _maxDamage != -1; - [SerializableFieldDefault(15)] private int MaxDamageDefaultValue() => -1; [SerializableProperty(16)] + [SaveFlag(nameof(ShouldSerializeHitSound), nameof(HitSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int HitSound { @@ -505,13 +488,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(16)] private bool ShouldSerializeHitSound() => _hitSound != -1; - [SerializableFieldDefault(16)] private int HitSoundDefaultValue() => -1; [SerializableProperty(17)] + [SaveFlag(nameof(ShouldSerializeMissSound), nameof(MissSoundDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MissSound { @@ -523,13 +505,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(17)] private bool ShouldSerializeMissSound() => _missSound != -1; - [SerializableFieldDefault(17)] private int MissSoundDefaultValue() => -1; [SerializableProperty(18)] + [SaveFlag(nameof(ShouldSerializeSpeed), nameof(SpeedDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public float Speed { @@ -560,13 +541,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(18)] private bool ShouldSerializeSpeed() => _speed != -1; - [SerializableFieldDefault(18)] private float SpeedDefaultValue() => -1; [SerializableProperty(19)] + [SaveFlag(nameof(ShouldSerializeMaxRange), nameof(MaxRangeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public int MaxRange { @@ -579,13 +559,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(19)] private bool ShouldSerializeMaxRange() => _maxRange != -1; - [SerializableFieldDefault(19)] private int MaxRangeDefaultValue() => -1; [SerializableProperty(20)] + [SaveFlag(nameof(ShouldSerializeSkill), nameof(SkillNameDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public SkillName Skill { @@ -598,13 +577,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(20)] private bool ShouldSerializeSkill() => _skill != (SkillName)(-1); - [SerializableFieldDefault(20)] private SkillName SkillNameDefaultValue() => (SkillName)(-1); [SerializableProperty(21)] + [SaveFlag(nameof(ShouldSerializeType), nameof(TypeDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponType Type { @@ -616,13 +594,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(21)] private bool ShouldSerializeType() => _type != (WeaponType)(-1); - [SerializableFieldDefault(21)] private WeaponType TypeDefaultValue() => (WeaponType)(-1); [SerializableProperty(22)] + [SaveFlag(nameof(ShouldSerializeAnimation), nameof(AnimationDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public WeaponAnimation Animation { @@ -634,13 +611,12 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(22)] private bool ShouldSerializeAnimation() => _animation != (WeaponAnimation)(-1); - [SerializableFieldDefault(22)] private WeaponAnimation AnimationDefaultValue() => (WeaponAnimation)(-1); [SerializableProperty(23)] + [SaveFlag(nameof(ShouldSerializeResource), nameof(ResourceDefaultValue))] [CommandProperty(AccessLevel.GameMaster)] public CraftResource Resource { @@ -656,10 +632,8 @@ public abstract partial class BaseWeapon } } - [SerializableFieldSaveFlag(23)] private bool ShouldSerializeResource() => _resource != CraftResource.Iron; - [SerializableFieldDefault(23)] private CraftResource ResourceDefaultValue() => CraftResource.Iron; public virtual int OnCraft( @@ -674,7 +648,6 @@ public abstract partial class BaseWeapon Crafter = from.RawName; } - PlayerConstructed = true; Identified = true; var resourceType = typeRes ?? craftItem.Resources[0].ItemType; @@ -3580,236 +3553,6 @@ public abstract partial class BaseWeapon } } - private static bool GetSaveFlag(OldSaveFlag flags, OldSaveFlag toGet) => (flags & toGet) != 0; - - private void Deserialize(IGenericReader reader, int version) - { - var flags = (OldSaveFlag)reader.ReadInt(); - - if (GetSaveFlag(flags, OldSaveFlag.DamageLevel)) - { - _damageLevel = (WeaponDamageLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.AccuracyLevel)) - { - _accuracyLevel = (WeaponAccuracyLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.DurabilityLevel)) - { - _durabilityLevel = (WeaponDurabilityLevel)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Quality)) - { - _quality = (WeaponQuality)reader.ReadInt(); - } - else - { - _quality = WeaponQuality.Regular; - } - - if (GetSaveFlag(flags, OldSaveFlag.Hits)) - { - _hitPoints = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxHits)) - { - _maxHitPoints = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Slayer)) - { - _slayer = (SlayerName)reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Poison)) - { - _poison = reader.ReadPoison(); - } - - if (GetSaveFlag(flags, OldSaveFlag.PoisonCharges)) - { - _poisonCharges = reader.ReadInt(); - } - - if (GetSaveFlag(flags, OldSaveFlag.Crafter)) - { - Timer.DelayCall(crafter => _crafter = crafter?.RawName, reader.ReadEntity()); - } - - if (GetSaveFlag(flags, OldSaveFlag.Identified)) - { - _identified = version >= 6 || reader.ReadBool(); - } - - if (GetSaveFlag(flags, OldSaveFlag.StrReq)) - { - _strRequirement = reader.ReadInt(); - } - else - { - _strRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.DexReq)) - { - _dexRequirement = reader.ReadInt(); - } - else - { - _dexRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.IntReq)) - { - _intRequirement = reader.ReadInt(); - } - else - { - _intRequirement = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MinDamage)) - { - _minDamage = reader.ReadInt(); - } - else - { - _minDamage = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxDamage)) - { - _maxDamage = reader.ReadInt(); - } - else - { - _maxDamage = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.HitSound)) - { - _hitSound = reader.ReadInt(); - } - else - { - _hitSound = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MissSound)) - { - _missSound = reader.ReadInt(); - } - else - { - _missSound = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.Speed)) - { - if (version < 9) - { - _speed = reader.ReadInt(); - } - else - { - _speed = reader.ReadFloat(); - } - } - else - { - _speed = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.MaxRange)) - { - _maxRange = reader.ReadInt(); - } - else - { - _maxRange = -1; - } - - if (GetSaveFlag(flags, OldSaveFlag.Skill)) - { - _skill = (SkillName)reader.ReadInt(); - } - else - { - _skill = (SkillName)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Type)) - { - _type = (WeaponType)reader.ReadInt(); - } - else - { - _type = (WeaponType)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Animation)) - { - _animation = (WeaponAnimation)reader.ReadInt(); - } - else - { - _animation = (WeaponAnimation)(-1); - } - - if (GetSaveFlag(flags, OldSaveFlag.Resource)) - { - _resource = (CraftResource)reader.ReadInt(); - } - else - { - _resource = CraftResource.Iron; - } - - Attributes = new AosAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.Attributes)) - { - Attributes.Deserialize(reader); - } - - WeaponAttributes = new AosWeaponAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.WeaponAttributes)) - { - WeaponAttributes.Deserialize(reader); - } - - PlayerConstructed = GetSaveFlag(flags, OldSaveFlag.PlayerConstructed); - - SkillBonuses = new AosSkillBonuses(this); - - if (GetSaveFlag(flags, OldSaveFlag.SkillBonuses)) - { - SkillBonuses.Deserialize(reader); - } - - if (GetSaveFlag(flags, OldSaveFlag.Slayer2)) - { - _slayer2 = (SlayerName)reader.ReadInt(); - } - - AosElementDamages = new AosElementAttributes(this); - - if (GetSaveFlag(flags, OldSaveFlag.ElementalDamages)) - { - AosElementDamages.Deserialize(reader); - } - - if (GetSaveFlag(flags, OldSaveFlag.EngravedText)) - { - _engravedText = reader.ReadString(); - } - } - [AfterDeserialization] private void AfterDeserialization() { @@ -3875,42 +3618,6 @@ public abstract partial class BaseWeapon } } - [Flags] - private enum OldSaveFlag - { - None = 0x00000000, - DamageLevel = 0x00000001, - AccuracyLevel = 0x00000002, - DurabilityLevel = 0x00000004, - Quality = 0x00000008, - Hits = 0x00000010, - MaxHits = 0x00000020, - Slayer = 0x00000040, - Poison = 0x00000080, - PoisonCharges = 0x00000100, - Crafter = 0x00000200, - Identified = 0x00000400, - StrReq = 0x00000800, - DexReq = 0x00001000, - IntReq = 0x00002000, - MinDamage = 0x00004000, - MaxDamage = 0x00008000, - HitSound = 0x00010000, - MissSound = 0x00020000, - Speed = 0x00040000, - MaxRange = 0x00080000, - Skill = 0x00100000, - Type = 0x00200000, - Animation = 0x00400000, - Resource = 0x00800000, - Attributes = 0x01000000, - WeaponAttributes = 0x02000000, - PlayerConstructed = 0x04000000, - SkillBonuses = 0x08000000, - Slayer2 = 0x10000000, - ElementalDamages = 0x20000000, - EngravedText = 0x40000000 - } } public enum CheckSlayerResult diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json new file mode 100644 index 000000000..defcbe100 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v11.json @@ -0,0 +1,189 @@ +{ + "version": 11, + "type": "Server.Engines.CannedEvil.ChampionSpawn", + "properties": [ + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByProximity", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextProximityTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "MaxLevel", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByValor", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DamageEntries", + "type": "System.Collections.Generic.Dictionary\u003CServer.Mobile, int\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule", + "0", + "int", + "PrimitiveTypeMigrationRule", + "1", + "" + ] + }, + { + "name": "ConfinedRoaming", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Idol", + "type": "Server.Engines.CannedEvil.IdolOfTheChampion", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HasBeenAdvanced", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnArea", + "type": "Server.Rectangle2D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect2D" + ] + }, + { + "name": "RandomizeType", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Kills", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Active", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Type", + "type": "Server.Engines.CannedEvil.ChampionSpawnType", + "rule": "EnumMigrationRule" + }, + { + "name": "Creatures", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RedSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "WhiteSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Platform", + "type": "Server.Engines.CannedEvil.ChampionPlatform", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Altar", + "type": "Server.Engines.CannedEvil.ChampionAltar", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ExpireDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Champion", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "RestartDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "RestartTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json b/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json new file mode 100644 index 000000000..956de91db --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.Virtues.VirtueContext.v1.json @@ -0,0 +1,119 @@ +{ + "version": 1, + "type": "Server.Engines.Virtues.VirtueContext", + "properties": [ + { + "name": "LastSacrificeGain", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastSacrificeLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "AvailableResurrects", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastJusticeLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastCompassionLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "NextCompassionDay", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "CompassionGains", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LastValorLoss", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "LastHonorUse", + "type": "System.DateTime", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "HonorActive", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "JusticeProtection", + "type": "Server.Mobiles.PlayerMobile", + "usesSaveFlag": true, + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "JusticeStatus", + "type": "Server.Engines.Virtues.JusticeProtectorStatus", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Values", + "type": "int[]", + "usesSaveFlag": true, + "rule": "ArrayMigrationRule", + "ruleArguments": [ + "int", + "PrimitiveTypeMigrationRule", + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json b/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json new file mode 100644 index 000000000..04f4a0199 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Ethics.Player.v2.json @@ -0,0 +1,50 @@ +{ + "version": 2, + "type": "Server.Ethics.Player", + "properties": [ + { + "name": "Mobile", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Power", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "History", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Steed", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Familiar", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Shield", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Ethic", + "type": "Server.Ethics.Ethic", + "rule": "SerializableInterfaceMigrationRule" + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json b/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json new file mode 100644 index 000000000..d7844e116 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseArmor.v10.json @@ -0,0 +1,207 @@ +{ + "version": 10, + "type": "Server.Items.BaseArmor", + "properties": [ + { + "name": "Attributes", + "type": "Server.AosAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "ArmorAttributes", + "type": "Server.AosArmorAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "PhysicalBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "FireBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "ColdBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "PoisonBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "EnergyBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Identified", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaxHitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "HitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Crafter", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Quality", + "type": "Server.Items.ArmorQuality", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Durability", + "type": "Server.Items.ArmorDurabilityLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "ProtectionLevel", + "type": "Server.Items.ArmorProtectionLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Resource", + "type": "Server.Items.CraftResource", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "BaseArmorRating", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "StrBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DexBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "IntBonus", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "StrRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "DexRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "IntRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "MeditationAllowance", + "type": "Server.Items.ArmorMeditationAllowance", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "SkillBonuses", + "type": "Server.AosSkillBonuses", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json b/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json new file mode 100644 index 000000000..09d5e07be --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseClothing.v8.json @@ -0,0 +1,90 @@ +{ + "version": 8, + "type": "Server.Items.BaseClothing", + "properties": [ + { + "name": "Resource", + "type": "Server.Items.CraftResource", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Attributes", + "type": "Server.AosAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "ClothingAttributes", + "type": "Server.AosArmorAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "SkillBonuses", + "type": "Server.AosSkillBonuses", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "Resistances", + "type": "Server.AosElementAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "MaxHitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "HitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "EncodedInt" + ] + }, + { + "name": "Crafter", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Quality", + "type": "Server.Items.ClothingQuality", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "StrRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json b/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json new file mode 100644 index 000000000..c04a03f3b --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseLight.v2.json @@ -0,0 +1,43 @@ +{ + "version": 2, + "type": "Server.Items.BaseLight", + "properties": [ + { + "name": "BurntOut", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Burning", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Duration", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "Protected", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "BurnTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json b/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json new file mode 100644 index 000000000..009666433 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.BaseWeapon.v11.json @@ -0,0 +1,246 @@ +{ + "version": 11, + "type": "Server.Items.BaseWeapon", + "properties": [ + { + "name": "DamageLevel", + "type": "Server.Items.WeaponDamageLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "AccuracyLevel", + "type": "Server.Items.WeaponAccuracyLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "DurabilityLevel", + "type": "Server.Items.WeaponDurabilityLevel", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Quality", + "type": "Server.Items.WeaponQuality", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "HitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaxHitPoints", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Slayer", + "type": "Server.Items.SlayerName", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Poison", + "type": "Server.Poison", + "usesSaveFlag": true, + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Poison" + ] + }, + { + "name": "PoisonCharges", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Crafter", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Identified", + "type": "bool", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "StrRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DexRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "IntRequirement", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MinDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaxDamage", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HitSound", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MissSound", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Speed", + "type": "float", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "MaxRange", + "type": "int", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Skill", + "type": "Server.SkillName", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Type", + "type": "Server.Items.WeaponType", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Animation", + "type": "Server.Items.WeaponAnimation", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Resource", + "type": "Server.Items.CraftResource", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "Attributes", + "type": "Server.AosAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "WeaponAttributes", + "type": "Server.AosWeaponAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "SkillBonuses", + "type": "Server.AosSkillBonuses", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "Slayer2", + "type": "Server.Items.SlayerName", + "usesSaveFlag": true, + "rule": "EnumMigrationRule" + }, + { + "name": "AosElementDamages", + "type": "Server.AosElementAttributes", + "usesSaveFlag": true, + "rule": "RawSerializableMigrationRule", + "ruleArguments": [ + "DeserializationRequiresParent" + ] + }, + { + "name": "EngravedText", + "type": "string", + "usesSaveFlag": true, + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json new file mode 100644 index 000000000..452e3ba7c --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v18.json @@ -0,0 +1,137 @@ +{ + "version": 18, + "type": "Server.Items.Corpse", + "properties": [ + { + "name": "RestoreEquip", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Flags", + "type": "Server.Items.CorpseFlag", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDeath", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "DeltaTime" + ] + }, + { + "name": "RestoreTable", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Point3D", + "PrimitiveUOTypeMigrationRule", + "1", + "Point3D" + ] + }, + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Looters", + "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Killer", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Aggressors", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "CorpseName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "AccessLevel", + "type": "Server.AccessLevel", + "rule": "EnumMigrationRule" + }, + { + "name": "Guild", + "type": "Server.Guilds.Guild", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "EquipItems", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "HairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json new file mode 100644 index 000000000..2cccb267a --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v19.json @@ -0,0 +1,137 @@ +{ + "version": 19, + "type": "Server.Items.Corpse", + "properties": [ + { + "name": "RestoreEquip", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Flags", + "type": "Server.Items.CorpseFlag", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDeath", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "RestoreTable", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Point3D", + "PrimitiveUOTypeMigrationRule", + "1", + "Point3D" + ] + }, + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Looters", + "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Killer", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Aggressors", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "CorpseName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "AccessLevel", + "type": "Server.AccessLevel", + "rule": "EnumMigrationRule" + }, + { + "name": "Guild", + "type": "Server.Guilds.Guild", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "EquipItems", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "HairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairItemId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "FacialHairHue", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json b/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json new file mode 100644 index 000000000..ef81d79cd --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.DeathRobe.v4.json @@ -0,0 +1,14 @@ +{ + "version": 4, + "type": "Server.Items.DeathRobe", + "properties": [ + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json b/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json new file mode 100644 index 000000000..adcf02ff6 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.DecayedCorpse.v3.json @@ -0,0 +1,14 @@ +{ + "version": 3, + "type": "Server.Items.DecayedCorpse", + "properties": [ + { + "name": "DecayTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json b/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json new file mode 100644 index 000000000..2384676a3 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.FillableContainer.v3.json @@ -0,0 +1,19 @@ +{ + "version": 3, + "type": "Server.Items.FillableContainer", + "properties": [ + { + "name": "ContentType", + "type": "Server.Items.FillableContentType", + "rule": "EnumMigrationRule" + }, + { + "name": "RespawnTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json b/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json new file mode 100644 index 000000000..267ca4709 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.MarkContainer.v1.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "type": "Server.Items.MarkContainer", + "properties": [ + { + "name": "AutoLock", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RelockTimer", + "type": "Server.Items.MarkContainer.InternalTimer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "TargetMap", + "type": "Server.Map", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Map" + ] + }, + { + "name": "Target", + "type": "Server.Point3D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "Description", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json b/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json new file mode 100644 index 000000000..8424276f6 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.PuzzleChestSolutionAndTime.v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "type": "Server.Items.PuzzleChestSolutionAndTime", + "properties": [ + { + "name": "When", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json b/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json new file mode 100644 index 000000000..0a61f7bfe --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.StarRoomGate.v2.json @@ -0,0 +1,22 @@ +{ + "version": 2, + "type": "Server.Items.StarRoomGate", + "properties": [ + { + "name": "Decays", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DecayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json b/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json new file mode 100644 index 000000000..3745c4428 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TransientItem.v2.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "type": "Server.Items.TransientItem", + "properties": [ + { + "name": "Expiration", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json new file mode 100644 index 000000000..805bbb6d1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v3.json @@ -0,0 +1,57 @@ +{ + "version": 3, + "type": "Server.Items.TreasureMapChest", + "properties": [ + { + "name": "Guardians", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Temporary", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ExpireTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@TimerDrift" + ] + }, + { + "name": "Lifted", + "type": "System.Collections.Generic.HashSet\u003CServer.Item\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json new file mode 100644 index 000000000..b4d1f85d4 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.TreasureMapChest.v4.json @@ -0,0 +1,57 @@ +{ + "version": 4, + "type": "Server.Items.TreasureMapChest", + "properties": [ + { + "name": "Guardians", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Temporary", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ExpireTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "Lifted", + "type": "System.Collections.Generic.HashSet\u003CServer.Item\u003E", + "rule": "HashSetMigrationRule", + "ruleArguments": [ + "@Tidy", + "@CanBeNull", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json b/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json new file mode 100644 index 000000000..5149f4347 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.BaseEscortable.v3.json @@ -0,0 +1,43 @@ +{ + "version": 3, + "type": "Server.Mobiles.BaseEscortable", + "properties": [ + { + "name": "DestinationString", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DeleteTimer", + "type": "Server.Timer", + "rule": "TimerMigrationRule", + "ruleArguments": [ + "@AnchoredTimer" + ] + }, + { + "name": "MlQuestType", + "type": "System.Type", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "MlQuestDestinationMessage", + "type": "Server.TextDefinition", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "TextDefinition" + ] + }, + { + "name": "MlQuestPaymentMessage", + "type": "Server.TextDefinition", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "TextDefinition" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json new file mode 100644 index 000000000..6b9c9eb1c --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.PlayerVendor.v4.json @@ -0,0 +1,62 @@ +{ + "version": 4, + "type": "Server.Mobiles.PlayerVendor", + "properties": [ + { + "name": "ShopName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextPayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "House", + "type": "Server.Multis.BaseHouse", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "BankAccount", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "HoldGold", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SellItems", + "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Mobiles.VendorItem\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Item", + "SerializableInterfaceMigrationRule", + "0", + "Server.Mobiles.VendorItem", + "RawSerializableMigrationRule", + "1", + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json new file mode 100644 index 000000000..b42fa33eb --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.RentedVendor.v1.json @@ -0,0 +1,62 @@ +{ + "version": 1, + "type": "Server.Mobiles.RentedVendor", + "properties": [ + { + "name": "RentalDurationId", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalPrice", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LandlordRenew", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RenterRenew", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RenewalPrice", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalGold", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "RentalExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json new file mode 100644 index 000000000..bd224f77f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.Sheep.v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "type": "Server.Mobiles.Sheep", + "properties": [ + { + "name": "NextWoolTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json b/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json new file mode 100644 index 000000000..140d3dd64 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Multis.BaseBoat.v5.json @@ -0,0 +1,73 @@ +{ + "version": 5, + "type": "Server.Multis.BaseBoat", + "properties": [ + { + "name": "MapItem", + "type": "Server.Items.MapItem", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "NextNavPoint", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Facing", + "type": "Server.Direction", + "rule": "EnumMigrationRule" + }, + { + "name": "TimeOfDecay", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Owner", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "PPlank", + "type": "Server.Items.Plank", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "SPlank", + "type": "Server.Items.Plank", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "TillerMan", + "type": "Server.Items.TillerMan", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Hold", + "type": "Server.Items.Hold", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Anchored", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ShipName", + "type": "string", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json b/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json new file mode 100644 index 000000000..471c645f1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Multis.BaseCamp.v2.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "type": "Server.Multis.BaseCamp", + "properties": [ + { + "name": "Items", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Mobiles", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "DecayTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json new file mode 100644 index 000000000..1f16a4d7f --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Fifth.PoisonField.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Fifth.PoisonField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json b/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json new file mode 100644 index 000000000..3e91c7a4d --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Fourth.FireFieldItem.v1.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "type": "Server.Spells.Fourth.FireFieldItem", + "properties": [ + { + "name": "Damage", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json b/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json new file mode 100644 index 000000000..dd202da03 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Seventh.EnergyField.v2.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "type": "Server.Spells.Seventh.EnergyField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json b/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json new file mode 100644 index 000000000..3e834a6b1 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Sixth.ParalyzeField.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Sixth.ParalyzeField", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json b/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json new file mode 100644 index 000000000..eafcc5945 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Spells.Third.WallOfStone.v1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "type": "Server.Spells.Third.WallOfStone", + "properties": [ + { + "name": "Caster", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "End", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Misc/AdminFirewall.cs b/Projects/UOContent/Misc/AdminFirewall.cs deleted file mode 100644 index a4275d90c..000000000 --- a/Projects/UOContent/Misc/AdminFirewall.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System; -using System.Buffers; -using System.IO; -using System.Net; -using System.Runtime.CompilerServices; -using Server.Logging; -using Server.Network; - -namespace Server; - -public static class AdminFirewall -{ - private static readonly ILogger logger = LogFactory.GetLogger(typeof(AdminFirewall)); - - private const string firewallConfigPath = "firewall.cfg"; - - public static void Configure() - { - if (File.Exists(firewallConfigPath)) - { - var searchValues = SearchValues.Create("*Xx?"); - - using var ip = new StreamReader(firewallConfigPath); - - while (ip.ReadLine() is { } line) - { - line = line.Trim(); - - if (line.Length == 0) - { - continue; - } - - if (line.AsSpan().ContainsAny(searchValues)) - { - logger.Warning("Legacy firewall entry \"{Entry}\" ignored", line); - continue; - } - - Add(ToFirewallEntry(line), false); - } - } - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static IFirewallEntry ToFirewallEntry(object entry) - { - return entry switch - { - IFirewallEntry firewallEntry => firewallEntry, - IPAddress address => new SingleIpFirewallEntry(address), - string s => ToFirewallEntry(s), - _ => null - }; - } - - public static IFirewallEntry ToFirewallEntry(string entry) - { - if (entry == null) - { - return null; - } - - try - { - var rangeSeparator = entry.IndexOf('-'); - if (rangeSeparator > -1) - { - return new CidrFirewallEntry( - IPAddress.Parse(entry.AsSpan(0, rangeSeparator)), - IPAddress.Parse(entry.AsSpan(rangeSeparator + 1)) - ); - } - - // CIDR notation - if (entry.IndexOf('/') > -1) - { - return new CidrFirewallEntry(entry); - } - - return new SingleIpFirewallEntry(entry); - } - catch - { - return null; - } - } - - public static bool Remove(object obj, bool save = true) - { - var entry = ToFirewallEntry(obj); - - if (entry == null) - { - return false; - } - - if (!Firewall.Remove(entry)) - { - return false; - } - - if (save) - { - Save(); - } - - return true; - } - - public static void Add(object obj) => Add(ToFirewallEntry(obj)); - - public static bool Add(IFirewallEntry entry, bool save = true) - { - if (!Firewall.Add(entry)) - { - return false; - } - - if (save) - { - Save(); - } - - return true; - } - - public static void Save() - { - Firewall.ReadFirewallSet(firewallSet => - { - using var op = new StreamWriter(firewallConfigPath); - foreach (var entry in firewallSet) - { - op.WriteLine(entry); - } - }); - } -} diff --git a/Projects/UOContent/Misc/ShardPoller.cs b/Projects/UOContent/Misc/ShardPoller.cs index 762a0be9c..8516e6678 100644 --- a/Projects/UOContent/Misc/ShardPoller.cs +++ b/Projects/UOContent/Misc/ShardPoller.cs @@ -36,16 +36,14 @@ public partial class ShardPoller : Item Movable = false; } - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public string Title + [SerializableField(0, allowFieldChange: nameof(AllowTitleChange))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private string _title; + + private bool AllowTitleChange(ref string value) { - get => _title; - set - { - _title = ShardPollPrompt.UrlToHref(value); - this.MarkDirty(); - } + value = ShardPollPrompt.UrlToHref(value); + return true; } [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] @@ -54,31 +52,20 @@ public partial class ShardPoller : Item ? TimeSpan.Zero : Utility.Max(StartTime + Duration - Core.Now, TimeSpan.Zero); - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] - public bool Active + [SerializableField(3, fieldChanged: nameof(OnActiveChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] + private bool _active; + + private void OnActiveChanged(bool oldValue, bool newValue) { - get => _active; - set + if (_active) { - if (_active == value) - { - return; - } - - _active = value; - - if (_active) - { - StartTime = Core.Now; - _activePollers.Add(this); - } - else - { - _activePollers.Remove(this); - } - - this.MarkDirty(); + StartTime = Core.Now; + _activePollers.Add(this); + } + else + { + _activePollers.Remove(this); } } @@ -250,15 +237,12 @@ public partial class ShardPollOption } } - [SerializableProperty(0)] - public string Title + [SerializableField(0, fieldChanged: nameof(OnTitleChanged))] + private string _title; + + private void OnTitleChanged(string oldValue, string newValue) { - get => _title; - set - { - _title = value; - _lineBreaks = -1; - } + _lineBreaks = -1; } public int Votes => Voters.Length; diff --git a/Projects/UOContent/Misc/StaminaSystem.cs b/Projects/UOContent/Misc/StaminaSystem.cs index ed8074cba..fcd5d46b0 100644 --- a/Projects/UOContent/Misc/StaminaSystem.cs +++ b/Projects/UOContent/Misc/StaminaSystem.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using ModernUO.CodeGeneratedEvents; -using Server.Collections; using Server.Logging; using Server.Mobiles; using Server.Spells.Ninjitsu; @@ -60,22 +59,16 @@ public static class StaminaSystem EventSink.Logout += Logout; // Credit idle time - using var queue = PooledRefQueue.Create(); foreach (var m in _stepsTaken.Keys) { - // We cannot remove since we are iterating. + // Keeps the ref valid for the check below. ref var stepsTaken = ref RegenSteps(m, out var exists, removeOnInvalidation: false); if (exists && stepsTaken.Steps <= 0) { - queue.Enqueue(m); + _stepsTaken.Remove(m); } } - - while (queue.Count > 0) - { - _stepsTaken.Remove(queue.Dequeue()); - } } [OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))] @@ -325,7 +318,7 @@ public static class StaminaSystem { var from = e.Mobile; var running = (e.Direction & Direction.Running) != 0; - + if (CannotWalkWhenFatigued && from.Stam <= 0) { from.SendLocalizedMessage(500110); // You are too fatigued to move. @@ -481,27 +474,13 @@ public static class StaminaSystem if (_resetHash.Count > 0) { - using var queue = PooledRefQueue.Create(); - ref var stepsTaken = ref Unsafe.NullRef(); foreach (var m in _resetHash) { stepsTaken = ref GetStepsTaken(m, out var exists); if (!exists || Core.Now >= stepsTaken.IdleStartTime + ResetDuration) { - queue.Enqueue(m); - } - } - - if (_resetHash.Count == queue.Count) - { - _resetHash.Clear(); - } - else - { - while (queue.Count > 0) - { - _resetHash.Remove(queue.Dequeue()); + _resetHash.Remove(m); } } } diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index dd461a03f..dedd914c9 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -47,7 +47,7 @@ public class ArcherAI : BaseAI { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); - if ((int)Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { this.DebugSayFormatted($"I have lost {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs index af6c00038..548fe0d54 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIGroupMovement.cs @@ -27,26 +27,41 @@ public abstract partial class BaseAI private static void CleanupReservedPositions() { - using var toRemove = PooledRefQueue.Create(); - foreach (var (m, p) in _reservedPositions) { if (m?.Deleted != false || m.GetDistanceToSqrt(p) < 1) { - toRemove.Enqueue(m); + _reservedPositions.Remove(m); } } - - while (toRemove.Count > 0) - { - _reservedPositions.Remove(toRemove.Dequeue()); - } } - private bool UseGroupMovement(Mobile target) => + /// + /// Crowding refinement for the final approach: engages only near the target when allies + /// contest the ring, so creatures spread instead of stacking. Chasing any real distance + /// always uses the pathfinding approach primitive. + /// + private bool UseGroupMovement(Mobile target, int range) => Mobile.Combatant == target && !Mobile.Controlled - && CountNearbyAllies(target) > 0; + && Mobile.InRange(target, range + 2) + && CountCrowdingAllies(target, range) > 0; + + private int CountCrowdingAllies(Mobile target, int range) + { + var crowding = 0; + + foreach (var m in target.GetMobilesInRange(range + 1)) + { + if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc + && bc.Team == Mobile.Team) + { + crowding++; + } + } + + return crowding; + } public static bool MoveToWithGroup(BaseAI ai, Mobile target, bool run, int range) { @@ -63,6 +78,7 @@ public abstract partial class BaseAI { if (optimalPosition == Point3D.Zero) { + return ai.MoveToWithCollisionAvoidance(target, run, range); } @@ -75,7 +91,15 @@ public abstract partial class BaseAI direction = GetAdjustedDirection(direction); } - return ai.DoMove(direction, true); + var res = ai.DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) + { + return true; + } + + // A blocked or wall-slid step is not progress — route around the obstacle. + return ai.ApproachTarget(target, run, range); } finally { @@ -83,21 +107,6 @@ public abstract partial class BaseAI } } - private int CountNearbyAllies(Mobile target) - { - var allies = 0; - foreach (var m in Mobile.GetMobilesInRange(8)) - { - if (m != Mobile && m.Combatant == target && m is BaseCreature { Controlled: false } bc - && bc.Team == Mobile.Team) - { - allies++; - } - } - - return allies; - } - private PooledRefList GetNearbyAllies(Mobile target) { var allies = PooledRefList.Create(); diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs index 1063dd70a..ccd1c72b6 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AIMovement.cs @@ -13,6 +13,7 @@ * along with this program. If not, see . * ************************************************************************/ +using System; using System.Runtime.CompilerServices; using Server.Collections; using Server.Items; @@ -37,7 +38,18 @@ public abstract partial class BaseAI private bool _approachGaveUp; private Point3D _approachGaveUpGoalLoc; - public static double BadlyHurtMoveDelay(BaseCreature bc) + // --- Move intent (see ContinueMove) ------------------------------------------------ + // Durable movement goal renewed by en-route ApproachTarget/MoveToPoint calls; while + // live, the AITimer wakes at NextMove between think ticks to advance the step. + private Mobile _moveIntentTarget; + private IPoint3D _moveIntentPoint; + private bool _moveIntentRun; + private int _moveIntentRange; + private long _moveIntentExpire; + + // Inflates a step delay while badly hurt; computed from the passed base so it cannot + // compound across steps. Damage slows steps, never decisions. + public static double BadlyHurtMoveDelay(BaseCreature bc, double delay) { var statMin = Core.HS ? bc.Stam : bc.Hits; var statMax = Core.HS ? bc.StamMax : bc.HitsMax; @@ -45,20 +57,40 @@ public abstract partial class BaseAI if (!bc.IsDeadPet && (bc.ReduceSpeedWithDamage || bc.IsSubdued) && statMax > 0 && statMin < statMax * 0.3) { - var hits = (double)statMin / statMax; + var stat = (double)statMin / statMax; - if (hits < 0.1) { return bc.CurrentSpeed + 0.15; } - if (hits < 0.2) { return bc.CurrentSpeed + 0.1; } - if (hits < 0.3) { return bc.CurrentSpeed + 0.05; } + if (stat < 0.1) { return delay + 0.15; } + if (stat < 0.2) { return delay + 0.1; } + + return delay + 0.05; } - return bc.CurrentSpeed; + return delay; } public bool CanMoveNow(out double delay) { delay = 0.0; - return Core.TickCount >= NextMove; + return Core.TickCount - NextMove >= 0; + } + + // Accumulative full-step budget: long-run pacing averages CurrentMoveSpeed exactly + // regardless of timer-grid jitter; snap-to-now caps stall catch-up at one step. + private void ConsumeMoveBudget() + { + var stepDelay = Mobile.CurrentMoveSpeed; + + if (!(Core.AOS && IsFollowingMaster())) + { + stepDelay = BadlyHurtMoveDelay(Mobile, stepDelay); + } + + NextMove += Math.Max(50, (long)(stepDelay * 1000)); + + if (Core.TickCount - NextMove > 0) + { + NextMove = Core.TickCount; + } } public virtual bool CheckMove() => !(Mobile.Deleted || Mobile.DisallowAllMoves); @@ -86,23 +118,22 @@ public abstract partial class BaseAI if (TryMove(d)) { + // Writes the think clock only; hurt slowdown applies in ConsumeMoveBudget. if (Core.AOS && IsFollowingMaster()) { Mobile.CurrentSpeed = 0.1; } - else if (Mobile.Hits < Mobile.HitsMax * 0.3) - { - Mobile.CurrentSpeed = BadlyHurtMoveDelay(Mobile); - } else if (Mobile.Warmode || Mobile.Combatant != null) { - Mobile.CurrentSpeed = Mobile.ActiveSpeed; + Mobile.SetCurrentSpeedToActive(); } else { - Mobile.CurrentSpeed = Mobile.PassiveSpeed; + Mobile.SetCurrentSpeedToPassive(); } + ConsumeMoveBudget(); + return MoveResult.Success; } @@ -151,6 +182,7 @@ public abstract partial class BaseAI if (Mobile.Move(Mobile.Direction)) { + ConsumeMoveBudget(); return MoveResult.SuccessAutoTurn; } } @@ -307,12 +339,14 @@ public abstract partial class BaseAI { if (Mobile.Deleted || Mobile.DisallowAllMoves || target?.Deleted != false) { + ClearMoveIntent(); return false; } if (Mobile.InRange(target, range)) { ResetApproach(); + ClearMoveIntent(); return true; } @@ -321,12 +355,15 @@ public abstract partial class BaseAI { if (target.Location == _approachGaveUpGoalLoc) { + ClearMoveIntent(); return false; } ResetApproach(); // target moved — try again fresh } + RenewMoveIntent(target, null, run, range); + // FAST PATH: greedy step toward the target, counted as success ONLY when the move // fully succeeded (not an auto-turn sidestep) and actually got us closer. An // auto-turn sidestep can reduce Euclidean distance while moving in the wrong @@ -341,31 +378,83 @@ public abstract partial class BaseAI if (res == MoveResult.BadState) { - return false; // not allowed to move this tick; not a stall + return true; // not allowed to move this tick (frozen/casting/throttled); not a failure } if (res == MoveResult.Success && Mobile.GetDistanceToSqrt(target) < distBefore) { + ResetApproach(); - return Mobile.InRange(target, range); + return true; // healthy en-route progress } + // else: fall through; let the PathFollower route around the obstacle. } // PLANNING PATH: a persistent PathFollower, never discarded by a greedy step. if (Path == null || Path.Goal != target) { + Path = new PathFollower(Mobile, target) { Mover = DoMoveImpl }; } + // Sample move-eligibility BEFORE the attempt: a successful step consumes the move + // budget, which would mask stall accounting and the progress signal. + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + if (Path.Follow(run, range)) { ResetApproach(); return true; } - TrackApproachProgress(target); - return false; + TrackApproachProgress(target, couldMove); + + // En-route progress is success; failure only when a move-eligible tick took no step + // (no working path), or the approach has given up. + var progressed = !_approachGaveUp && (Mobile.Location != locBefore || !couldMove); + + return progressed; + } + + /// + /// Walks toward a fixed point (e.g. a target's last-known position), pathfinding around + /// obstacles. Returns false on arrival or when genuinely unable to make progress. + /// + public bool MoveToPoint(IPoint3D goal, bool run) + { + if (Mobile.Deleted || Mobile.DisallowAllMoves || goal == null) + { + ClearMoveIntent(); + return false; + } + + if (Path?.Goal != goal) + { + Path = new PathFollower(Mobile, goal) { Mover = DoMoveImpl }; + } + + RenewMoveIntent(null, goal, run, 1); + + var couldMove = CanMoveNow(out _) && !IsInBadState(); + var locBefore = Mobile.Location; + + if (Path.Follow(run, 1)) + { + Path = null; + ClearMoveIntent(); + return false; // arrived + } + + var progressed = Mobile.Location != locBefore || !couldMove; + + if (!progressed) + { + ClearMoveIntent(); + } + + return progressed; } /// @@ -376,11 +465,11 @@ public abstract partial class BaseAI /// gives up and idles. A MOVING goal (an active chase) resets the baseline every tick, /// so chases never give up even when the gap holds constant. /// - private void TrackApproachProgress(Mobile target) + private void TrackApproachProgress(Mobile target, bool couldMove) { - if (!CanMoveNow(out _)) + if (!couldMove) { - return; // a not-yet-due move (stun) is not a stall + return; // a tick that was never allowed to move (stun, stall) is not a stall } var dist = Mobile.GetDistanceToSqrt(target); @@ -411,6 +500,7 @@ public abstract partial class BaseAI _approachGaveUp = true; _approachGaveUpGoalLoc = goalLoc; Path = null; + ClearMoveIntent(); } } @@ -426,6 +516,56 @@ public abstract partial class BaseAI _approachGaveUp = false; } + private void RenewMoveIntent(Mobile target, IPoint3D point, bool run, int range) + { + _moveIntentTarget = target; + _moveIntentPoint = point; + _moveIntentRun = run; + _moveIntentRange = range; + + // A live pursuit renews every think tick; unrenewed intent dies on its own. + _moveIntentExpire = Core.TickCount + (long)(Mobile.CurrentSpeed * 2000) + 250; + } + + public void ClearMoveIntent() + { + _moveIntentTarget = null; + _moveIntentPoint = null; + } + + /// + /// True while a durable movement goal is live; is the tick + /// the movement budget elapses. + /// + public bool TryGetMoveWake(out long nextMove) + { + nextMove = NextMove; + + return (_moveIntentTarget != null || _moveIntentPoint != null) && + Core.TickCount - _moveIntentExpire < 0; + } + + /// + /// Advances the current pursuit/investigation by one step on a movement-clock wake; + /// no decisions run. + /// + public void ContinueMove() + { + if (!TryGetMoveWake(out var nextMove) || Core.TickCount - nextMove < 0) + { + return; + } + + if (_moveIntentTarget != null) + { + ApproachTarget(_moveIntentTarget, _moveIntentRun, _moveIntentRange); + } + else + { + MoveToPoint(_moveIntentPoint, _moveIntentRun); + } + } + public virtual bool MoveTo(Mobile m, bool run, int range) { if (Mobile.Deleted || Mobile.DisallowAllMoves || m?.Deleted != false) @@ -444,7 +584,7 @@ public abstract partial class BaseAI return true; } - if (UseGroupMovement(m)) + if (UseGroupMovement(m, range)) { return MoveToWithGroup(this, m, shouldRun, range); } @@ -467,7 +607,11 @@ public abstract partial class BaseAI var direction = Mobile.GetDirectionTo(target); - if (DoMove(direction, true)) + // Wall-slide auto-turns must not count as progress, or a creature pinned on + // geometry reports success forever. + var res = DoMoveImpl(direction, true); + + if (res is MoveResult.Success or MoveResult.BadState) { return true; } @@ -476,14 +620,14 @@ public abstract partial class BaseAI { var clockwise = (Direction)(((int)direction + i) % 8); - if (DoMove(clockwise, true)) + if (DoMoveImpl(clockwise, true) == MoveResult.Success) { return true; } var counterclockwise = (Direction)(((int)direction - i + 8) % 8); - if (DoMove(counterclockwise, true)) + if (DoMoveImpl(counterclockwise, true) == MoveResult.Success) { return true; } diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs index 3c08d944b..fe24e5f6d 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/AITimer.cs @@ -17,9 +17,15 @@ using System; namespace Server.Mobiles; +/// +/// Drives an AI on two clocks: decisions at , plus +/// move-only wakes at while a pursuit is live. Each tick +/// schedules the earlier of the two deadlines. +/// public sealed class AITimer : Timer { private readonly BaseAI _owner; + private long _nextThink; private int _detectHiddenMinDelay; private int _detectHiddenMaxDelay; @@ -28,14 +34,29 @@ public sealed class AITimer : Timer { _owner = owner; _owner._nextDetectHidden = Core.TickCount; + _nextThink = Core.TickCount; } public void Activate() { + _nextThink = Core.TickCount; Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); Start(); } + // A speed-up must not wait out a stale, longer think deadline. + public void OnSpeedChanged() + { + var candidate = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + + if (candidate - _nextThink < 0) + { + _nextThink = candidate; + } + + Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); + } + protected override void OnTick() { if (ShouldStop()) @@ -44,23 +65,52 @@ public sealed class AITimer : Timer return; } - _owner.Mobile.OnThink(); - - if (ShouldStop()) + if (Core.TickCount - _nextThink >= 0) { - Stop(); - return; + _owner.Mobile.OnThink(); + + if (ShouldStop()) + { + Stop(); + return; + } + + HandleBardEffects(); + + if (_owner.Mobile.Controlled ? _owner.Obey() : _owner.Think()) + { + HandleDetectHidden(); + } + + // Cadence from the post-decision speed (decisions may flip active/passive). + _nextThink = Core.TickCount + (long)(_owner.Mobile.CurrentSpeed * 1000); + } + else + { + _owner.ContinueMove(); } - Interval = TimeSpan.FromSeconds(_owner.Mobile.CurrentSpeed); - HandleBardEffects(); + ScheduleNext(); + } - if (_owner.Mobile.Controlled ? !_owner.Obey() : !_owner.Think()) + private void ScheduleNext() + { + var now = Core.TickCount; + var delay = _nextThink - now; + + if (_owner.TryGetMoveWake(out var nextMove)) { - return; + var moveDelay = nextMove - now; + + // Only a future budget is a wake — a blocked creature must not spin the timer. + if (moveDelay > 0 && moveDelay < delay) + { + delay = moveDelay; + } } - HandleDetectHidden(); + // The wheel rounds up to its 8ms resolution; a non-positive delay becomes one turn. + Interval = TimeSpan.FromMilliseconds(delay); } private bool ShouldStop() diff --git a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs index 57b6f6a25..cf64cae17 100644 --- a/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs +++ b/Projects/UOContent/Mobiles/AI/BaseAI/BaseAI.cs @@ -26,11 +26,26 @@ namespace Server.Mobiles; public abstract partial class BaseAI { + // Last-known-position tracking: recorded while the combatant is in LOS; drives the + // guard-time investigation and the instant re-engage. + private const int GuardGraceDuration = 10_000; + private const int LkpFreshDuration = 30_000; + private const int InvestigateDuration = 15_000; + private ActionType _action; public long _nextDetectHidden; public DateTime _lastOrder = DateTime.MinValue; public Mobile _commandIssuer; + private Mobile _lkpTarget; + private Point3D _lkpLocation; + private IPoint3D _lkpGoal; // boxed _lkpLocation handed to the PathFollower + private IPoint3D _herdGoal; // boxed herding goal handed to the PathFollower + private long _lkpExpireTick; + private long _guardStopTick; + private long _investigateStopTick; + private bool _investigating; + public PathFollower Path { get; protected set; } public AITimer AITimer { get; } public long NextMove { get; set; } @@ -44,6 +59,7 @@ public abstract partial class BaseAI public BaseAI(BaseCreature m) { Mobile = m; + NextMove = Core.TickCount; AITimer = new AITimer(this); if (!m.PlayerRangeSensitive || !World.Loading && m.Map != null && m.Map != Map.Internal && m.Map.GetSector(m.Location).Active) @@ -233,6 +249,15 @@ public abstract partial class BaseAI return true; } + if (_action == ActionType.Combat) + { + UpdateLastKnownLocation(); + } + else if (_action is ActionType.Wander or ActionType.Guard) + { + TryReengageLastKnown(); + } + switch (Action) { case ActionType.Wander: @@ -272,6 +297,9 @@ public abstract partial class BaseAI public virtual void OnActionChanged() { + // A change of course invalidates between-think movement continuation. + ClearMoveIntent(); + switch (Action) { case ActionType.Wander: @@ -323,6 +351,15 @@ public abstract partial class BaseAI { Mobile.Warmode = true; Mobile.Combatant = null; + + // Investigate a fresh last-seen position that is not already in view; the guard + // grace period begins once the investigation ends. + _investigating = _lkpTarget != null && Core.TickCount - _lkpExpireTick < 0 && + !(Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)); + _investigateStopTick = Core.TickCount + InvestigateDuration; + _guardStopTick = Core.TickCount + GuardGraceDuration; + _lkpGoal = null; } private void HandleFleeAction() @@ -453,18 +490,118 @@ public abstract partial class BaseAI public virtual bool DoActionGuard() { - if (Mobile.Combatant == null) + if (_investigating) { - DebugSay("No threats found. Going home..."); - Action = ActionType.Wander; + if (InvestigateLastKnown()) + { + return true; + } + + _investigating = false; + _guardStopTick = Core.TickCount + GuardGraceDuration; } - DebugSay("I stopped being on guard."); + if (Core.TickCount - _guardStopTick < 0) + { + DebugSay("I am on guard."); + + if (Utility.Random(8) == 0) + { + Mobile.Direction = (Direction)Utility.Random(8); + } + + return true; + } + + DebugSay("I stopped being on guard. Going home..."); Action = ActionType.Wander; return true; } + /// + /// Records the combatant's position while it is visible and in line of sight. + /// + private void UpdateLastKnownLocation() + { + var combatant = Mobile.Combatant; + + if (combatant?.Deleted == false && combatant.Map == Mobile.Map && + Mobile.CanSee(combatant) && Mobile.InLOS(combatant)) + { + _lkpTarget = combatant; + _lkpLocation = combatant.Location; + _lkpExpireTick = Core.TickCount + LkpFreshDuration; + } + } + + /// + /// Re-engages the last-seen target when it returns to view within perception range, + /// bypassing the reacquire throttle. + /// + private bool TryReengageLastKnown() + { + var target = _lkpTarget; + + if (target == null) + { + return false; + } + + if (target.Deleted || !target.Alive || target.Map != Mobile.Map || + target is BaseCreature { IsDeadPet: true } || Core.TickCount - _lkpExpireTick >= 0) + { + ClearLastKnown(); + return false; + } + + if (Mobile.Controlled || Mobile.BardPacified || Mobile.BardProvoked || Mobile.FightMode == FightMode.None) + { + return false; + } + + if (!Mobile.InRange(target, Mobile.RangePerception) || !Mobile.CanSee(target) || + !Mobile.InLOS(target) || !Mobile.CanBeHarmful(target, false)) + { + return false; + } + + DebugSay("There you are!"); + Mobile.Combatant = target; + Mobile.FocusMob = null; + Action = ActionType.Combat; + return true; + } + + /// + /// Walks toward the last-seen position until it is in view, reached, timed out, or + /// unreachable. Returns false when the investigation is finished. + /// + private bool InvestigateLastKnown() + { + if (_lkpTarget == null || Core.TickCount - _investigateStopTick >= 0) + { + return false; + } + + if (Mobile.InRange(_lkpLocation, 1) || + Mobile.InLOS(_lkpLocation) && Mobile.InRange(_lkpLocation, Mobile.RangePerception)) + { + DebugSay("They truly disappeared..."); + return false; + } + + _lkpGoal ??= _lkpLocation; + return MoveToPoint(_lkpGoal, false); + } + + private void ClearLastKnown() + { + _lkpTarget = null; + _lkpGoal = null; + _investigating = false; + } + public virtual bool DoActionFlee() { var from = Mobile.Combatant; @@ -491,6 +628,7 @@ public abstract partial class BaseAI if (target == null) { + _herdGoal = null; return false; } @@ -498,7 +636,15 @@ public abstract partial class BaseAI if (distance >= 1 && distance <= 15) { - DoMove(Mobile.GetDirectionTo(target)); + // A cached boxed goal keeps the PathFollower persistent across ticks; walking + // through MoveToPoint paces herding on the movement clock and paths around + // obstacles. + if (_herdGoal == null || _herdGoal.X != target.X || _herdGoal.Y != target.Y) + { + _herdGoal = new Point3D(target.X, target.Y, Mobile.Map?.GetAverageZ(target.X, target.Y) ?? Mobile.Z); + } + + MoveToPoint(_herdGoal, false); return true; } @@ -508,6 +654,7 @@ public abstract partial class BaseAI } Mobile.TargetLocation = null; + _herdGoal = null; return false; } @@ -999,6 +1146,6 @@ public abstract partial class BaseAI public virtual void OnCurrentSpeedChanged() { - AITimer.Interval = TimeSpan.FromSeconds(Mobile.CurrentSpeed); + AITimer.OnSpeedChanged(); } } diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index e9450db98..61be59a1f 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -679,7 +679,7 @@ public class MageAI : BaseAI Mobile.Combatant = Mobile.FocusMob; Mobile.FocusMob = null; } - else if (!Mobile.InRange(c, Mobile.RangePerception * 3)) + else if (!Mobile.InRange(c, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } @@ -695,6 +695,23 @@ public class MageAI : BaseAI } } + // Geometry (not hiding — CanSee passed above) is blocking the shot: close in until + // line of sight returns. Poisoned mages still fall through to cure. + if (!Mobile.Poisoned && Mobile.Spell?.IsCasting != true && !Mobile.InLOS(c)) + { + DebugSay("I cannot see my target, moving to regain line of sight"); + + if (!MoveTo(c, false, 1)) + { + OnFailedMove(); + } + + _lastTarget = c; + _lastTargetLoc = c.Location; + + return true; + } + if (Mobile.TriggerAbility(MonsterAbilityTrigger.CombatAction, c)) { DebugSay("I used my abilities!"); @@ -1018,7 +1035,16 @@ public class MageAI : BaseAI if (toTarget != null) { - RunTo(toTarget); + // Without line of sight the stand-off is pointless — close in so the held + // target can be invoked. + if (!Mobile.InLOS(toTarget)) + { + MoveTo(toTarget, true, 1); + } + else + { + RunTo(toTarget); + } } } diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index aa262caee..544770069 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -82,7 +82,7 @@ public class MeleeAI : BaseAI return true; } - if (!Mobile.InRange(combatant, Mobile.RangePerception * 3)) + if (!Mobile.InRange(combatant, Mobile.ChaseLeashRange)) { Mobile.Combatant = null; } diff --git a/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs b/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs index c74698d7c..ff7937ded 100644 --- a/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs +++ b/Projects/UOContent/Mobiles/Abilities/DestroyEquipment.cs @@ -1,4 +1,5 @@ using Server.Collections; +using Server.Engines.Insurance; using Server.Items; namespace Server.Mobiles; @@ -23,7 +24,7 @@ public class DestroyEquipment : MonsterAbilitySingleTarget continue; } - if (Mobile.InsuranceEnabled && item.Insured) + if (Insurance.Enabled && item.Insured) { continue; } diff --git a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs index b43c34272..741093110 100644 --- a/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs +++ b/Projects/UOContent/Mobiles/Animals/Misc/Sheep.cs @@ -5,9 +5,14 @@ using System.Runtime.CompilerServices; namespace Server.Mobiles { - [SerializationGenerator(0, false)] + [SerializationGenerator(1, false)] public partial class Sheep : BaseCreature, ICarvable { + private void MigrateFrom(V0Content content) + { + _nextWoolTime = content.NextWoolTime; + } + [Constructible] public Sheep() : base(AIType.AI_Animal, FightMode.Aggressor) { @@ -43,18 +48,14 @@ namespace Server.Mobiles public override string CorpseName => "a sheep corpse"; - [DeltaDateTime] - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime NextWoolTime + [SerializableField(0, fieldChanged: nameof(OnNextWoolTimeChanged))] + [AnchoredDateTime] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _nextWoolTime; + + private void OnNextWoolTimeChanged(DateTime oldValue, DateTime newValue) { - get => _nextWoolTime; - set - { - _nextWoolTime = value; - SheepBody(); - this.MarkDirty(); - } + SheepBody(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs index f13cebcbe..f6dc13b40 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/Ethereals.cs @@ -11,19 +11,19 @@ namespace Server.Mobiles public partial class EtherealMount : Item, IMount, IMountItem, IRewardItem { [SerializableField(0)] + [SaveFlag(nameof(ShouldSerializeIsDonationItem))] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] public bool _isDonationItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SerializableFieldSaveFlag(0)] public bool ShouldSerializeIsDonationItem() => _isDonationItem; [SerializableField(1)] + [SaveFlag(nameof(ShouldSerializeIsRewardItem))] [SerializedCommandProperty(AccessLevel.GameMaster)] public bool _isRewardItem; [MethodImpl(MethodImplOptions.AggressiveInlining)] - [SerializableFieldSaveFlag(1)] public bool ShouldSerializeIsRewardItem() => _isRewardItem; [Constructible] @@ -40,43 +40,27 @@ namespace Server.Mobiles public override double DefaultWeight => 1.0; - [SerializableProperty(2)] - [CommandProperty(AccessLevel.GameMaster)] - public int MountedID - { - get => _mountedID; - set - { - if (_mountedID != value) - { - _mountedID = value; + [SerializableField(2, fieldChanged: nameof(OnMountedIDChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _mountedID; - if (_rider != null) - { - ItemID = value; - } - this.MarkDirty(); - } + private void OnMountedIDChanged(int oldValue, int newValue) + { + if (_rider != null) + { + ItemID = newValue; } } - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public int RegularID - { - get => _regularID; - set - { - if (_regularID != value) - { - _regularID = value; + [SerializableField(3, fieldChanged: nameof(OnRegularIDChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private int _regularID; - if (_rider == null) - { - ItemID = value; - } - this.MarkDirty(); - } + private void OnRegularIDChanged(int oldValue, int newValue) + { + if (_rider == null) + { + ItemID = newValue; } } @@ -87,6 +71,7 @@ namespace Server.Mobiles public virtual int EtherealHue => 0x4001; [SerializableProperty(4)] + [SaveFlag(nameof(ShouldSerializeRider))] [CommandProperty(AccessLevel.GameMaster)] public Mobile Rider { @@ -124,22 +109,19 @@ namespace Server.Mobiles } } - [SerializableFieldSaveFlag(4)] private bool ShouldSerializeRider() => _rider != null; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(5)] - public int Steps + [SerializableField(5, allowFieldChange: nameof(AllowStepsChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [SaveFlag(nameof(ShouldSerializeSteps))] + private int _steps; + + private bool AllowStepsChange(ref int value) { - get => _steps; - set - { - _steps = Math.Clamp(value, 0, StepsMax); - this.MarkDirty(); - } + value = Math.Clamp(value, 0, StepsMax); + return true; } - [SerializableFieldSaveFlag(5)] private bool ShouldSerializeSteps() => _steps != StepsMax; public virtual int StepsMax => 3840; // Should be same as horse diff --git a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs index 69bd9ce56..19a061af2 100644 --- a/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs +++ b/Projects/UOContent/Mobiles/Animals/Mounts/SwampDragon.cs @@ -62,48 +62,37 @@ namespace Server.Mobiles [SerializableField(3)] private int _bardingHP; - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(2)] - public bool HasBarding - { - get => _hasBarding; - set - { - _hasBarding = value; + [SerializableField(2, fieldChanged: nameof(OnHasBardingChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _hasBarding; - if (_hasBarding) - { - Hue = CraftResources.GetHue(_bardingResource); - Body = 0x31F; - ItemID = 0x3EBE; - } - else - { - Hue = 0x851; - Body = 0x31A; - ItemID = 0x3EBD; - } - InvalidateProperties(); - this.MarkDirty(); + private void OnHasBardingChanged(bool oldValue, bool newValue) + { + if (_hasBarding) + { + Hue = CraftResources.GetHue(_bardingResource); + Body = 0x31F; + ItemID = 0x3EBE; + } + else + { + Hue = 0x851; + Body = 0x31A; + ItemID = 0x3EBD; } } - [CommandProperty(AccessLevel.GameMaster)] - [SerializableProperty(4)] - public CraftResource BardingResource + [SerializableField(4, fieldChanged: nameof(OnBardingResourceChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private CraftResource _bardingResource; + + private void OnBardingResourceChanged(CraftResource oldValue, CraftResource newValue) { - get => _bardingResource; - set + if (_hasBarding) { - _bardingResource = value; - - if (_hasBarding) - { - Hue = CraftResources.GetHue(value); - } - - InvalidateProperties(); - this.MarkDirty(); + Hue = CraftResources.GetHue(newValue); } } diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index af85a195a..f64a289f4 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -265,8 +265,12 @@ namespace Server.Mobiles private double _passiveSpeed; private double _currentSpeed; - // Herding - Overrides the AI to force the mob to move to a specific location - // Thinking: 0.3s, Movement: 0.6s. + // Movement clock (seconds per step); 0 = inherit the matching think value. + private double _activeMoveSpeed; + private double _passiveMoveSpeed; + + // Herding - forces the mob to walk to a specific location, paced by the movement + // clock at HerdingMoveSpeed. Thinking is unaffected. private IPoint2D _targetLocation; private int m_DamageMax = -1; @@ -342,6 +346,7 @@ namespace Server.Mobiles FightMode = mode; GetSpeeds(out var activeSpeed, out var passiveSpeed); + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); ActiveSpeed = activeSpeed; PassiveSpeed = passiveSpeed; @@ -660,12 +665,20 @@ namespace Server.Mobiles [CommandProperty(AccessLevel.GameMaster)] public int RangePerception { get; set; } + /// + /// How far a chase may stretch before the creature gives up its combatant. Between + /// RangePerception and this leash it keeps chasing but may switch to closer targets. + /// + [CommandProperty(AccessLevel.GameMaster)] + public virtual int ChaseLeashRange => RangePerception * 2; + [CommandProperty(AccessLevel.GameMaster)] public int RangeFight { get; set; } [CommandProperty(AccessLevel.GameMaster)] public int RangeHome { get; set; } = 10; + /// Seconds per AI decision while engaged; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double ActiveSpeed { @@ -679,6 +692,7 @@ namespace Server.Mobiles } } + /// Seconds per AI decision while idle; see for movement pace. [CommandProperty(AccessLevel.GameMaster)] public virtual double PassiveSpeed { @@ -693,21 +707,37 @@ namespace Server.Mobiles } } + /// Seconds per step while engaged. Inherits ; set 0 to re-inherit. + [CommandProperty(AccessLevel.GameMaster)] + public virtual double ActiveMoveSpeed + { + get => _activeMoveSpeed > 0 ? _activeMoveSpeed : _activeSpeed; + set => _activeMoveSpeed = value > 0 ? value : 0; + } + + /// Seconds per step while idle. Inherits ; set 0 to re-inherit. + [CommandProperty(AccessLevel.GameMaster)] + public virtual double PassiveMoveSpeed + { + get => _passiveMoveSpeed > 0 ? _passiveMoveSpeed : _passiveSpeed; + set => _passiveMoveSpeed = value > 0 ? value : 0; + } + + // Herded creatures walk at a fixed standard pace regardless of their own speed + // (RunUO's forced 0.3, without its TransformMoveDelay inflation to 0.6). + private const double HerdingMoveSpeed = 0.3; + [CommandProperty(AccessLevel.GameMaster)] public IPoint2D TargetLocation { get => _targetLocation; - set - { - _targetLocation = value; - AIObject?.OnCurrentSpeedChanged(); - } + set => _targetLocation = value; } [CommandProperty(AccessLevel.GameMaster)] public double CurrentSpeed { - get => _targetLocation != null ? 0.3 : _currentSpeed; + get => _currentSpeed; set { if (Math.Abs(_currentSpeed - value) > 0.0001) @@ -718,8 +748,26 @@ namespace Server.Mobiles } } + /// + /// Resolved seconds per step: a verbatim active/passive + /// maps to the matching movement value; a bespoke pace stays fused to both clocks. + /// A herded creature is always driven at . + /// [CommandProperty(AccessLevel.GameMaster)] - public double MoveSpeedMod { get; set; } + public double CurrentMoveSpeed + { + get + { + if (_targetLocation != null) + { + return HerdingMoveSpeed; + } + + return _currentSpeed == _activeSpeed ? ActiveMoveSpeed + : _currentSpeed == _passiveSpeed ? PassiveMoveSpeed + : _currentSpeed; + } + } [CommandProperty(AccessLevel.GameMaster)] public Point3D Home @@ -1843,7 +1891,7 @@ namespace Server.Mobiles { base.Serialize(writer); - writer.Write(20); // version + writer.Write(22); // version writer.Write((int)m_CurrentAI); writer.Write((int)m_DefaultAI); @@ -1880,7 +1928,7 @@ namespace Server.Mobiles if (_summoned) { - writer.WriteDeltaTime(SummonEnd); + writer.WriteAnchoredTime(SummonEnd); } writer.Write(ControlSlots); @@ -1963,6 +2011,10 @@ namespace Server.Mobiles // Version 19 writer.Write(HomeMap); + + // Version 22 (0 = inherit the matching think value) + writer.Write(_activeMoveSpeed); + writer.Write(_passiveMoveSpeed); } public override void Deserialize(IGenericReader reader) @@ -2035,7 +2087,7 @@ namespace Server.Mobiles if (_summoned) { - SummonEnd = reader.ReadDeltaTime(); + SummonEnd = version >= 21 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); new UnsummonTimer(this, SummonEnd - Core.Now).Start(); } @@ -2166,6 +2218,16 @@ namespace Server.Mobiles HomeMap = reader.ReadMap(); } + if (version >= 22) + { + _activeMoveSpeed = reader.ReadDouble(); + _passiveMoveSpeed = reader.ReadDouble(); + } + else + { + MigrateMoveSpeeds(); + } + if (version <= 14 && m_Paragon && Hue == 0x31) { Hue = Paragon.Hue; // Paragon hue fixed, should now be 0x501. @@ -4579,13 +4641,78 @@ namespace Server.Mobiles return false; } + /// + /// Sets the think clock and clears movement overrides (legacy one-clock semantics); + /// use for an independent movement pace. + /// public void SetSpeed(double active, double passive, bool isPassive = true) { ActiveSpeed = active; PassiveSpeed = passive; + ClearMoveSpeed(); CurrentSpeed = isPassive ? PassiveSpeed : ActiveSpeed; } + /// Sets only the movement clock (seconds per step). + public void SetMoveSpeed(double active, double passive) + { + ActiveMoveSpeed = active; + PassiveMoveSpeed = passive; + } + + /// Clears movement overrides; steps pace off the think clock again. + public void ClearMoveSpeed() + { + _activeMoveSpeed = 0; + _passiveMoveSpeed = 0; + } + + /// + /// Scales movement overrides (paragon and similar buffs). Inheriting values stay + /// inheriting — they already follow the scaled think clock. + /// + public void ScaleMoveSpeed(double scalar) + { + if (_activeMoveSpeed > 0) + { + _activeMoveSpeed *= scalar; + } + + if (_passiveMoveSpeed > 0) + { + _passiveMoveSpeed *= scalar; + } + } + + /// + /// Snaps speeds within rounding distance of the creature's table values back to + /// exact. A scaling buff that divides then multiplies can drift by an ulp (e.g. + /// 0.9 and 0.45 through 1.2), which would read as hand-tuned; call after undoing + /// such a buff. Genuinely tuned speeds are nowhere near the epsilon and keep. + /// + public void SnapSpeedsToTable() + { + GetSpeeds(out var activeSpeed, out var passiveSpeed); + + if (Math.Abs(_activeSpeed - activeSpeed) < 0.0001 && Math.Abs(_passiveSpeed - passiveSpeed) < 0.0001) + { + _activeSpeed = activeSpeed; + _passiveSpeed = passiveSpeed; + } + + GetMoveSpeeds(out var activeMoveSpeed, out var passiveMoveSpeed); + + if (activeMoveSpeed > 0 && Math.Abs(_activeMoveSpeed - activeMoveSpeed) < 0.0001) + { + _activeMoveSpeed = activeMoveSpeed; + } + + if (passiveMoveSpeed > 0 && Math.Abs(_passiveMoveSpeed - passiveMoveSpeed) < 0.0001) + { + _passiveMoveSpeed = passiveMoveSpeed; + } + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void SetCurrentSpeedToActive() => CurrentSpeed = ActiveSpeed; @@ -4899,6 +5026,25 @@ namespace Server.Mobiles NPCSpeeds.GetSpeeds(this, out activeSpeed, out passiveSpeed); } + public virtual void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed) + { + NPCSpeeds.GetMoveSpeeds(this, out activeMoveSpeed, out passiveMoveSpeed); + } + + // Pre-v22 saves carry no movement clock. A creature whose serialized think speeds + // still match what it would spawn with today was never hand-tuned: adopt today's + // move values so existing worlds (and pets) pick up npc-speeds pacing without a + // respawn. Tuned creatures keep movement inheriting their think clock. + internal void MigrateMoveSpeeds() + { + GetSpeeds(out var activeSpeed, out var passiveSpeed); + + if (_activeSpeed == activeSpeed && _passiveSpeed == passiveSpeed) + { + GetMoveSpeeds(out _activeMoveSpeed, out _passiveMoveSpeed); + } + } + public virtual void DropBackpack() { var backpack = Backpack; diff --git a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs index e3fbadd97..3f96e5d36 100644 --- a/Projects/UOContent/Mobiles/Hireables/BaseHire.cs +++ b/Projects/UOContent/Mobiles/Hireables/BaseHire.cs @@ -23,19 +23,14 @@ public partial class BaseHire : BaseCreature public int GoldOnDeath { get; set; } - [SerializableProperty(1)] - [CommandProperty(AccessLevel.GameMaster)] - public bool IsHired - { - get => _isHired; - set - { - _isHired = value; + [SerializableField(1, fieldChanged: nameof(OnIsHiredChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private bool _isHired; - Delta(MobileDelta.Noto); - InvalidateProperties(); - this.MarkDirty(); - } + private void OnIsHiredChanged(bool oldValue, bool newValue) + { + Delta(MobileDelta.Noto); } public BaseHire(AIType AI) : base(AI, FightMode.Aggressor) diff --git a/Projects/UOContent/Mobiles/NPCSpeeds.cs b/Projects/UOContent/Mobiles/NPCSpeeds.cs index a61ce70c6..8f5e908bb 100644 --- a/Projects/UOContent/Mobiles/NPCSpeeds.cs +++ b/Projects/UOContent/Mobiles/NPCSpeeds.cs @@ -38,6 +38,22 @@ public static class NPCSpeeds passiveSpeed = sp.PassiveSpeed; } + // Move speeds are optional (0 = inherit), so this tolerates a missing entry or table. + public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed) + { + if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) && + !_speedsByType.TryGetValue(bc.GetType(), out sp) && + !_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp)) + { + activeMoveSpeed = 0; + passiveMoveSpeed = 0; + return; + } + + activeMoveSpeed = sp.ActiveMoveSpeed; + passiveMoveSpeed = sp.PassiveMoveSpeed; + } + public static void RegisterSpeed(SpeedClassEntry entry) { _speedsByLevel[entry.Level] = entry; @@ -78,6 +94,13 @@ public static class NPCSpeeds [JsonPropertyName("passive")] public double PassiveSpeed { get; init; } + // Movement clock (seconds per step); absent/0 = inherit the matching think value. + [JsonPropertyName("activeMove")] + public double ActiveMoveSpeed { get; init; } + + [JsonPropertyName("passiveMove")] + public double PassiveMoveSpeed { get; init; } + [JsonPropertyName("types")] public HashSet Types { get; init; } } diff --git a/Projects/UOContent/Mobiles/PlayerMobile.cs b/Projects/UOContent/Mobiles/PlayerMobile.cs index 0a81bb846..83c0e4cca 100644 --- a/Projects/UOContent/Mobiles/PlayerMobile.cs +++ b/Projects/UOContent/Mobiles/PlayerMobile.cs @@ -11,6 +11,7 @@ using Server.Engines.CannedEvil; using Server.Engines.ConPVP; using Server.Engines.Craft; using Server.Engines.Help; +using Server.Engines.Insurance; using Server.Engines.MLQuests; using Server.Engines.MLQuests.Gumps; using Server.Engines.PartySystem; @@ -161,7 +162,7 @@ namespace Server.Mobiles /* * a value of zero means, that the mobile is not executing the spell. Otherwise, * the value should match the BaseMana required - */ + */ private RankDefinition m_GuildRank; @@ -169,9 +170,6 @@ namespace Server.Mobiles public DateTime _honorTime; - private Mobile m_InsuranceAward; - private int m_InsuranceBonus; - private int m_LastGlobalLight = -1, m_LastPersonalLight = -1; private bool m_LastProtectedMessage; @@ -190,9 +188,6 @@ namespace Server.Mobiles private bool m_NoDeltaRecursion; - // number of items that could not be automatically reinsured because gold in bank was not enough - private int m_NonAutoreinsuredItems; - private DateTime m_SavagePaintExpiration; private DateTime[] m_StuckMenuUses; @@ -908,17 +903,17 @@ namespace Server.Mobiles { Direction.North => itemIDs[0], Direction.South => itemIDs[0], - Direction.East => itemIDs[1], - Direction.West => itemIDs[1], - _ => item.ItemID + Direction.East => itemIDs[1], + Direction.West => itemIDs[1], + _ => item.ItemID }, 4 => dir switch { Direction.South => itemIDs[0], - Direction.East => itemIDs[1], + Direction.East => itemIDs[1], Direction.North => itemIDs[2], - Direction.West => itemIDs[3], - _ => item.ItemID + Direction.West => itemIDs[3], + _ => item.ItemID }, _ => item.ItemID }; @@ -989,7 +984,7 @@ namespace Server.Mobiles } if (skillId == 35) - // AnimalTaming.DeferredTarget = true; + // AnimalTaming.DeferredTarget = true; { AnimalTaming.DisableMessage = false; } @@ -1845,7 +1840,7 @@ namespace Server.Mobiles // moving, not teleporting var zDrop = Location.Z - loc.Z; - if (zDrop > 20) // we fell more than one story + if (zDrop > 20) // we fell more than one story { Hits -= zDrop / 20 * 10 - 5; // deal some damage; does not kill, disrupt, etc } @@ -1874,7 +1869,7 @@ namespace Server.Mobiles if (Alive) { - if (InsuranceEnabled) + if (Insurance.Enabled) { if (Core.SA) { @@ -2002,7 +1997,7 @@ namespace Server.Mobiles { var house = BaseHouse.FindHouseAt(this); - if (CheckAlive() && house?.IsOwner(this) == true && house.InternalizedVendors.Count > 0 && NetState is NetState { } ns) + if (CheckAlive() && house?.IsOwner(this) == true && house.InternalizedVendors.Count > 0 && NetState != null) { ReclaimVendorGump.DisplayTo(this, house); } @@ -2310,7 +2305,8 @@ namespace Server.Mobiles pm.DuelPlayer.Eliminated) || base.OnMoveOver(m); public override bool CheckShove(Mobile shoved) => - IgnoreMobiles || shoved.IgnoreMobiles || TransformationSpellHelper.UnderTransformation(shoved, typeof(WraithFormSpell)) || + IgnoreMobiles || shoved.IgnoreMobiles || + TransformationSpellHelper.UnderTransformation(shoved, typeof(WraithFormSpell)) || base.CheckShove(shoved); protected override void OnMapChange(Map oldMap) @@ -2401,7 +2397,8 @@ namespace Server.Mobiles [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool FindItems_Callback(Item item) => - !item.Deleted && (item.LootType == LootType.Blessed || item.Insured) && Backpack != item.Parent; + !item.Deleted && Backpack != item.Parent && + (item.LootType == LootType.Blessed || ServerFeatureFlags.InsuranceEnabled && item.Insured); public override bool OnBeforeDeath() { @@ -2422,30 +2419,8 @@ namespace Server.Mobiles } } - EquipSnapshot = new List(Items); - - m_NonAutoreinsuredItems = 0; - m_InsuranceAward = FindMostRecentDamager(false); - - if (m_InsuranceAward is BaseCreature creature) - { - var master = creature.GetMaster(); - - if (master != null) - { - m_InsuranceAward = master; - } - } - - if (m_InsuranceAward != null && (!m_InsuranceAward.Player || m_InsuranceAward == this)) - { - m_InsuranceAward = null; - } - - if (m_InsuranceAward is PlayerMobile mobile) - { - mobile.m_InsuranceBonus = 0; - } + EquipSnapshot = [..Items]; + Insurance.CheckInsuranceBeforeDeath(this); ReceivedHonorContext?.OnTargetKilled(); SentHonorContext?.OnSourceKilled(); @@ -2453,56 +2428,6 @@ namespace Server.Mobiles return base.OnBeforeDeath(); } - private bool CheckInsuranceOnDeath(Item item) - { - if (!InsuranceEnabled || !item.Insured) - { - return false; - } - - if (DuelContext?.Registered == true && DuelContext.Started && - m_DuelPlayer?.Eliminated != true) - { - return true; - } - - if (AutoRenewInsurance) - { - var cost = GetInsuranceCost(item); - - if (m_InsuranceAward != null) - { - cost /= 2; - } - - if (Banker.Withdraw(this, cost)) - { - item.PaidInsurance = true; - // ~1_AMOUNT~ gold has been withdrawn from your bank box. - SendLocalizedMessage(1060398, cost.ToString()); - } - else - { - SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance - item.PaidInsurance = false; - item.Insured = false; - m_NonAutoreinsuredItems++; - } - } - else - { - item.PaidInsurance = false; - item.Insured = false; - } - - if (m_InsuranceAward is PlayerMobile insurancePm && Banker.Deposit(m_InsuranceAward, 300)) - { - insurancePm.m_InsuranceBonus += 300; - } - - return true; - } - public override DeathMoveResult GetParentMoveResultFor(Item item) { // It seems all items are unmarked on death, even blessed/insured ones @@ -2511,7 +2436,7 @@ namespace Server.Mobiles item.QuestItem = false; } - if (CheckInsuranceOnDeath(item)) + if (Insurance.CheckItemInsuranceOnDeath(this, item)) { return DeathMoveResult.MoveToBackpack; } @@ -2534,7 +2459,7 @@ namespace Server.Mobiles item.QuestItem = false; } - if (CheckInsuranceOnDeath(item)) + if (Insurance.CheckItemInsuranceOnDeath(this, item)) { return DeathMoveResult.MoveToBackpack; } @@ -2554,11 +2479,6 @@ namespace Server.Mobiles public override void OnDeath(Container c) { - if (m_NonAutoreinsuredItems > 0) - { - SendLocalizedMessage(1061115); - } - base.OnDeath(c); EquipSnapshot = null; @@ -2627,11 +2547,7 @@ namespace Server.Mobiles } } - if (m_InsuranceAward is PlayerMobile insurancePm && insurancePm.m_InsuranceBonus > 0) - { - // ~1_AMOUNT~ gold has been deposited into your bank box. - insurancePm.SendLocalizedMessage(1060397, insurancePm.m_InsuranceBonus.ToString()); - } + Insurance.CheckInsuranceOnDeath(this); var killer = FindMostRecentDamager(true); @@ -2911,7 +2827,8 @@ namespace Server.Mobiles for (var i = 0; i < recipeCount; i++) { var r = reader.ReadInt(); - if (version > 33 || reader.ReadBool()) // Don't add in recipes which we haven't gotten or have been removed + // Don't add in recipes which we haven't gotten or have been removed + if (version > 33 || reader.ReadBool()) { _acquiredRecipes.Add(r); } @@ -2926,6 +2843,7 @@ namespace Server.Mobiles { reader.ReadDeltaTime(); // LastHonorLoss - Not even used } + goto case 23; } case 23: @@ -3021,6 +2939,7 @@ namespace Server.Mobiles { virtues.LastCompassionLoss = reader.ReadDeltaTime(); } + goto case 14; } case 14: @@ -3710,213 +3629,13 @@ namespace Server.Mobiles AutoStabled = null; } - private static int GetInsuranceCost(Item item) => 600; + private void ToggleItemInsurance() => Insurance.ToggleItemInsurance(this); - private void ToggleItemInsurance() - { - if (!CheckAlive()) - { - return; - } + private void OpenItemInsuranceMenu() => Insurance.OpenItemInsuranceMenu(this); - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - SendLocalizedMessage(1060868); // Target the item you wish to toggle insurance status on to cancel - } + private void CancelRenewInventoryInsurance() => Insurance.CancelRenewInventoryInsurance(this); - private bool CanInsure(Item item) - { - if (item is Container && item is not BaseQuiver || item is BagOfSending or KeyRing or PotionKeg or Sigil) - { - return false; - } - - if (item.Stackable) - { - return false; - } - - if (item.LootType == LootType.Cursed) - { - return false; - } - - if (item.ItemID == 0x204E) // death shroud - { - return false; - } - - if (item.Layer == Layer.Mount) - { - return false; - } - - return item.LootType != LootType.Blessed && item.LootType != LootType.Newbied && item.BlessedFor != this; - } - - private void ToggleItemInsurance_Callback(Mobile from, object obj) - { - if (!CheckAlive()) - { - return; - } - - ToggleItemInsurance_Callback(from, obj as Item, true); - } - - private void ToggleItemInsurance_Callback(Mobile from, Item item, bool target) - { - if (item?.IsChildOf(this) != true) - { - if (target) - { - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - } - - SendLocalizedMessage( - 1060871, - "", - 0x23 - ); // You can only insure items that you have equipped or that are in your backpack - } - else if (item.Insured) - { - item.Insured = false; - - SendLocalizedMessage(1060874, "", 0x35); // You cancel the insurance on the item - - if (target) - { - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - SendLocalizedMessage( - 1060868, - "", - 0x23 - ); // Target the item you wish to toggle insurance status on to cancel - } - } - else if (!CanInsure(item)) - { - if (target) - { - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - } - - SendLocalizedMessage(1060869, "", 0x23); // You cannot insure that - } - else - { - if (!item.PaidInsurance) - { - var cost = GetInsuranceCost(item); - - if (Banker.Withdraw(from, cost)) - { - SendLocalizedMessage( - 1060398, - cost.ToString() - ); // ~1_AMOUNT~ gold has been withdrawn from your bank box. - item.PaidInsurance = true; - } - else - { - SendLocalizedMessage(1061079, "", 0x23); // You lack the funds to purchase the insurance - return; - } - } - - item.Insured = true; - - SendLocalizedMessage(1060873, "", 0x23); // You have insured the item - - if (target) - { - BeginTarget(-1, false, TargetFlags.None, ToggleItemInsurance_Callback); - SendLocalizedMessage( - 1060868, - "", - 0x23 - ); // Target the item you wish to toggle insurance status on to cancel - } - } - } - - private void AutoRenewInventoryInsurance() - { - if (!CheckAlive()) - { - return; - } - - // You have selected to automatically reinsure all insured items upon death - SendLocalizedMessage(1060881, "", 0x23); - AutoRenewInsurance = true; - } - - private void CancelRenewInventoryInsurance() - { - if (!CheckAlive()) - { - return; - } - - if (Core.SE) - { - NetState?.SendGump(new CancelRenewInventoryInsuranceGump(null)); - } - else - { - // You have cancelled automatically reinsuring all insured items upon death - SendLocalizedMessage(1061075, "", 0x23); - AutoRenewInsurance = false; - } - } - - private void OpenItemInsuranceMenu() - { - if (!CheckAlive()) - { - return; - } - - using var queue = PooledRefQueue.Create(128); - - foreach (var item in Items) - { - if (DisplayInItemInsuranceGump(item)) - { - queue.Enqueue(item); - } - } - - var pack = Backpack; - - if (pack != null) - { - foreach (var item in pack.FindItems()) - { - if (DisplayInItemInsuranceGump(item)) - { - queue.Enqueue(item); - } - } - } - - // TODO: Investigate item sorting - if (NetState != null) - { - if (queue.Count == 0) - { - SendLocalizedMessage(1114915, "", 0x35); // None of your current items meet the requirements for insurance. - } - else - { - NetState.SendGump(new ItemInsuranceMenuGump(this, queue.ToArray())); - } - } - } - - private bool DisplayInItemInsuranceGump(Item item) => (item.Visible || AccessLevel >= AccessLevel.GameMaster) && - (item.Insured || CanInsure(item)); + private void AutoRenewInventoryInsurance() => Insurance.AutoRenewInventoryInsurance(this); private void ToggleQuestItem() { @@ -4022,7 +3741,7 @@ namespace Server.Mobiles if (EvilOmenSpell.EndEffect(this)) { - poison = PoisonImpl.IncreaseLevel(poison); + poison = Poison.IncreaseLevel(poison); } var result = base.ApplyPoison(from, poison); @@ -4473,7 +4192,9 @@ namespace Server.Mobiles var offset = duration.TotalMilliseconds - roundedSeconds * TimeSpan.MillisecondsPerSecond; if (offset > 0) { - Timer.DelayCall(TimeSpan.FromMilliseconds(offset), () => + Timer.DelayCall( + TimeSpan.FromMilliseconds(offset), + () => { // They are still online, we still have the buff icon in the table, and it is the same buff icon if (NetState != null && m_BuffTable?.GetValueOrDefault(buffInfo.ID) == buffInfo) @@ -4598,295 +4319,5 @@ namespace Server.Mobiles m_Callback?.Invoke(); } } - - private class CancelRenewInventoryInsuranceGump : StaticGump - { - private readonly ItemInsuranceMenuGump _insuranceGump; - - public override bool Singleton => true; - - public CancelRenewInventoryInsuranceGump(ItemInsuranceMenuGump insuranceGump) : base(250, 200) => - _insuranceGump = insuranceGump; - - protected override void BuildLayout(ref StaticGumpBuilder builder) - { - builder.AddBackground(0, 0, 240, 142, 0x13BE); - builder.AddImageTiled(6, 6, 228, 100, 0xA40); - builder.AddImageTiled(6, 116, 228, 20, 0xA40); - builder.AddAlphaRegion(6, 6, 228, 142); - - // You are about to disable inventory insurance auto-renewal. - builder.AddHtmlLocalized(8, 8, 228, 100, 1071021, 0x7FFF); - - builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); - builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL - - builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); - builder.AddHtmlLocalized(148, 118, 450, 20, 1071022, 0x7FFF); // DISABLE IT! - } - - public override void OnResponse(NetState sender, in RelayInfo info) - { - if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) - { - return; - } - - if (info.ButtonID == 1) - { - // You have cancelled automatically reinsuring all insured items upon death - pm.SendLocalizedMessage(1061075, "", 0x23); - pm.AutoRenewInsurance = false; - } - else - { - pm.SendLocalizedMessage(1042021); // Cancelled. - } - - if (_insuranceGump != null) - { - pm.SendGump(_insuranceGump); - } - } - } - - private class ItemInsuranceMenuGump : DynamicGump - { - private readonly PlayerMobile _from; - private readonly bool[] _insure; - private readonly Item[] _items; - private int _page; - - public override bool Singleton => true; - - public ItemInsuranceMenuGump(PlayerMobile from, Item[] items) : base(25, 50) - { - _from = from; - _items = items; - _insure = new bool[items.Length]; - - for (var i = 0; i < items.Length; ++i) - { - _insure[i] = items[i].Insured; - } - } - - protected override void BuildLayout(ref DynamicGumpBuilder builder) - { - builder.AddPage(); - - builder.AddBackground(0, 0, 520, 510, 0x13BE); - builder.AddImageTiled(10, 10, 500, 30, 0xA40); - builder.AddImageTiled(10, 50, 500, 355, 0xA40); - builder.AddImageTiled(10, 415, 500, 80, 0xA40); - builder.AddAlphaRegion(10, 10, 500, 485); - - builder.AddButton(15, 470, 0xFB1, 0xFB2, 0); - builder.AddHtmlLocalized(50, 472, 80, 20, 1011012, 0x7FFF); // CANCEL - - if (_from.AutoRenewInsurance) - { - builder.AddButton(360, 10, 9723, 9724, 1); - } - else - { - builder.AddButton(360, 10, 9720, 9722, 1); - } - - builder.AddHtmlLocalized(395, 14, 105, 20, 1114122, 0x7FFF); // AUTO REINSURE - - builder.AddButton(395, 470, 0xFA5, 0xFA6, 2); - builder.AddHtmlLocalized(430, 472, 50, 20, 1006044, 0x7FFF); // OK - - builder.AddHtmlLocalized(10, 14, 150, 20, 1114121, 0x7FFF); //
ITEM INSURANCE MENU
- - builder.AddHtmlLocalized(45, 54, 70, 20, 1062214, 0x7FFF); // Item - builder.AddHtmlLocalized(250, 54, 70, 20, 1061038, 0x7FFF); // Cost - builder.AddHtmlLocalized(400, 54, 70, 20, 1114311, 0x7FFF); // Insured - - var balance = Banker.GetBalance(_from); - var cost = 0; - - for (var i = 0; i < _items.Length; ++i) - { - if (_insure[i]) - { - cost += GetInsuranceCost(_items[i]); - } - } - - builder.AddHtmlLocalized(15, 420, 300, 20, 1114310, 0x7FFF); // GOLD AVAILABLE: - builder.AddLabel(215, 420, 0x481, balance.ToString()); - builder.AddHtmlLocalized(15, 435, 300, 20, 1114123, 0x7FFF); // TOTAL COST OF INSURANCE: - builder.AddLabel(215, 435, 0x481, cost.ToString()); - - if (cost != 0) - { - builder.AddHtmlLocalized(15, 450, 300, 20, 1114125, 0x7FFF); // NUMBER OF DEATHS PAYABLE: - builder.AddLabel(215, 450, 0x481, (balance / cost).ToString()); - } - - for (int i = _page * 4, y = 72; i < (_page + 1) * 4 && i < _items.Length; ++i, y += 75) - { - var item = _items[i]; - var b = ItemBounds.Bounds[item.ItemID]; - - builder.AddImageTiledButton( - 40, - y, - 0x918, - 0x918, - 0, - GumpButtonType.Page, - 0, - item.ItemID, - item.Hue, - 40 - b.Width / 2 - b.X, - 30 - b.Height / 2 - b.Y - ); - builder.AddItemProperty(item.Serial); - - if (_insure[i]) - { - builder.AddButton(400, y, 9723, 9724, 100 + i); - builder.AddLabel(250, y, 0x481, GetInsuranceCost(item).ToString()); - } - else - { - builder.AddButton(400, y, 9720, 9722, 100 + i); - builder.AddLabel(250, y, 0x66C, GetInsuranceCost(item).ToString()); - } - } - - if (_page >= 1) - { - builder.AddButton(15, 380, 0xFAE, 0xFAF, 3); - builder.AddHtmlLocalized(50, 380, 450, 20, 1044044, 0x7FFF); // PREV PAGE - } - - if ((_page + 1) * 4 < _items.Length) - { - builder.AddButton(400, 380, 0xFA5, 0xFA7, 4); - builder.AddHtmlLocalized(435, 380, 70, 20, 1044045, 0x7FFF); // NEXT PAGE - } - } - - public override void OnResponse(NetState sender, in RelayInfo info) - { - if (info.ButtonID == 0 || !_from.CheckAlive()) - { - return; - } - - switch (info.ButtonID) - { - case 1: // Auto Reinsure - { - if (_from.AutoRenewInsurance) - { - _from.SendGump(new CancelRenewInventoryInsuranceGump(this)); - } - else - { - _from.AutoRenewInventoryInsurance(); - _from.SendGump(this); - } - - break; - } - case 2: // OK - { - _from.SendGump(new ItemInsuranceMenuConfirmGump(this)); - - break; - } - case 3: // Prev - { - if (_page >= 1) - { - _page--; - _from.SendGump(this); - } - - break; - } - case 4: // Next - { - if ((_page + 1) * 4 < _items.Length) - { - _page++; - _from.SendGump(this); - } - - break; - } - default: - { - var idx = info.ButtonID - 100; - - if (idx >= 0 && idx < _items.Length) - { - _insure[idx] = !_insure[idx]; - } - - _from.SendGump(this); - - break; - } - } - } - - private class ItemInsuranceMenuConfirmGump : StaticGump - { - private readonly ItemInsuranceMenuGump _parentGump; - - public ItemInsuranceMenuConfirmGump(ItemInsuranceMenuGump parentGump) : base(250, 200) => - _parentGump = parentGump; - - protected override void BuildLayout(ref StaticGumpBuilder builder) - { - builder.AddBackground(0, 0, 240, 142, 0x13BE); - builder.AddImageTiled(6, 6, 228, 100, 0xA40); - builder.AddImageTiled(6, 116, 228, 20, 0xA40); - builder.AddAlphaRegion(6, 6, 228, 142); - - builder.AddHtmlLocalized(8, 8, 228, 100, 1114300, 0x7FFF); // Do you wish to insure all newly selected items? - - builder.AddButton(6, 116, 0xFB1, 0xFB2, 0); - builder.AddHtmlLocalized(40, 118, 450, 20, 1060051, 0x7FFF); // CANCEL - - builder.AddButton(114, 116, 0xFA5, 0xFA7, 1); - builder.AddHtmlLocalized(148, 118, 450, 20, 1073996, 0x7FFF); // ACCEPT - } - - public override void OnResponse(NetState sender, in RelayInfo info) - { - if (sender.Mobile is not PlayerMobile pm || !pm.CheckAlive()) - { - return; - } - - if (info.ButtonID == 1) - { - var items = _parentGump._items; - var insure = _parentGump._insure; - for (var i = 0; i < items.Length; ++i) - { - var item = items[i]; - - if (item.Insured != insure[i]) - { - pm.ToggleItemInsurance_Callback(pm, item, false); - } - } - } - else - { - pm.SendLocalizedMessage(1042021); // Cancelled. - pm.SendGump(_parentGump); - } - } - } - } } } diff --git a/Projects/UOContent/Mobiles/Special/Paragon.cs b/Projects/UOContent/Mobiles/Special/Paragon.cs index 825609e22..cbd0a30c7 100644 --- a/Projects/UOContent/Mobiles/Special/Paragon.cs +++ b/Projects/UOContent/Mobiles/Special/Paragon.cs @@ -79,6 +79,7 @@ public static class Paragon bc.PassiveSpeed /= SpeedBuff; bc.ActiveSpeed /= SpeedBuff; + bc.ScaleMoveSpeed(1.0 / SpeedBuff); bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin += DamageBuff; @@ -143,6 +144,8 @@ public static class Paragon bc.PassiveSpeed *= SpeedBuff; bc.ActiveSpeed *= SpeedBuff; + bc.ScaleMoveSpeed(SpeedBuff); + bc.SnapSpeedsToTable(); // an ulp of scaling drift must not read as hand-tuned bc.CurrentSpeed = bc.PassiveSpeed; bc.DamageMin -= DamageBuff; diff --git a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs index 462307be3..cf439a76c 100644 --- a/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs +++ b/Projects/UOContent/Mobiles/Townfolk/BaseEscortable.cs @@ -17,7 +17,7 @@ using EDI = Server.Mobiles.EscortDestinationInfo; namespace Server.Mobiles; -[SerializationGenerator(2, false)] +[SerializationGenerator(3, false)] public partial class BaseEscortable : BaseCreature { private static readonly ILogger logger = LogFactory.GetLogger(typeof(BaseEscortable)); @@ -158,16 +158,22 @@ public partial class BaseEscortable : BaseCreature [SerializableField(0, setter: "private")] private string _destinationString; - [TimerDrift] [SerializableField(1)] + [DeserializeTimer(nameof(DeserializeDeleteTimer))] private Timer _deleteTimer; - [DeserializeTimerField(1)] - private void DeserializeDeleteTimer(TimeSpan delay) + private void DeserializeDeleteTimer(TimeSpan delay) => Timer.DelayCall(delay, Delete); + + private void MigrateFrom(V2Content content) { - if (delay >= TimeSpan.Zero) + _destinationString = content.DestinationString; + _mlQuestType = content.MlQuestType; + _mlQuestDestinationMessage = content.MlQuestDestinationMessage; + _mlQuestPaymentMessage = content.MlQuestPaymentMessage; + + if (content.DeleteTimerDelay != TimeSpan.MinValue) { - Timer.DelayCall(delay, Delete); + DeserializeDeleteTimer(content.DeleteTimerDelay); } } diff --git a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs index d80c60dcc..bbbc3ff6f 100644 --- a/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs +++ b/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs @@ -148,18 +148,13 @@ public partial class PlayerBarkeeper : BaseVendor LoadSBInfo(); } - [SerializableProperty(0)] - public BaseHouse House - { - get => _house; - set - { - _house?.PlayerBarkeepers.Remove(this); - value?.PlayerBarkeepers.Add(this); + [SerializableField(0, fieldChanged: nameof(OnHouseChanged))] + private BaseHouse _house; - _house = value; - this.MarkDirty(); - } + private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + { + oldValue?.PlayerBarkeepers.Remove(this); + newValue?.PlayerBarkeepers.Add(this); } public override bool IsActiveBuyer => false; diff --git a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs index 2ff6eb812..9b8f5a2f3 100644 --- a/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/PlayerVendor.cs @@ -22,9 +22,20 @@ public class PlayerVendorTargetAttribute : Attribute; * Next, uncomment the MigrateFrom function and change the `V3Content` type to match the serialization version * before it was bumped. Then run publish.cmd to generate the migration file. */ -[SerializationGenerator(3, false)] +[SerializationGenerator(4, false)] public partial class PlayerVendor : Mobile { + private void MigrateFrom(V3Content content) + { + _shopName = content.ShopName; + _nextPayTime = content.NextPayTime; + _house = content.House; + _owner = content.Owner; + _bankAccount = content.BankAccount; + _holdGold = content.HoldGold; + _sellItems = content.SellItems; + } + private Timer _payTimer; [InvalidateProperties] @@ -32,7 +43,7 @@ public partial class PlayerVendor : Mobile [SerializedCommandProperty(AccessLevel.GameMaster)] private string _shopName; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1, setter: "private")] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _nextPayTime; @@ -94,18 +105,13 @@ public partial class PlayerVendor : Mobile public PlayerVendorPlaceholder Placeholder { get; set; } - [SerializableProperty(2)] - public BaseHouse House - { - get => _house; - set - { - _house?.PlayerVendors.Remove(this); - value?.PlayerVendors.Add(this); + [SerializableField(2, fieldChanged: nameof(OnHouseChanged))] + private BaseHouse _house; - _house = value; - this.MarkDirty(); - } + private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue) + { + oldValue?.PlayerVendors.Remove(this); + newValue?.PlayerVendors.Add(this); } public int ChargePerDay diff --git a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs index 99d818826..d7d0a3ab6 100644 --- a/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs +++ b/Projects/UOContent/Mobiles/Vendors/RentedVendor.cs @@ -46,9 +46,20 @@ public class VendorRentalDuration } } -[SerializationGenerator(0)] +[SerializationGenerator(1)] public partial class RentedVendor : PlayerVendor { + private void MigrateFrom(V0Content content) + { + _rentalDurationId = content.RentalDurationId; + _rentalPrice = content.RentalPrice; + _landlordRenew = content.LandlordRenew; + _renterRenew = content.RenterRenew; + _renewalPrice = content.RenewalPrice; + _rentalGold = content.RentalGold; + _rentalExpireTime = content.RentalExpireTime; + } + private Timer _rentalExpireTimer; public RentedVendor( @@ -93,7 +104,7 @@ public partial class RentedVendor : PlayerVendor [SerializedCommandProperty(AccessLevel.GameMaster)] private int _rentalGold; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(6)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _rentalExpireTime; diff --git a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs index d4d5ce4bd..8f0a1fba8 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorInventory.cs @@ -37,7 +37,7 @@ namespace Server.Mobiles Items = reader.ReadEntityList(); Gold = reader.ReadInt(); - ExpireTime = reader.ReadDeltaTime(); + ExpireTime = version >= 1 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); if (Items.Count == 0 && Gold == 0) { @@ -88,7 +88,7 @@ namespace Server.Mobiles public void Serialize(IGenericWriter writer) { - writer.WriteEncodedInt(0); // version + writer.WriteEncodedInt(1); // version writer.Write(Owner); writer.Write(VendorName); @@ -98,7 +98,7 @@ namespace Server.Mobiles writer.Write(Items); writer.Write(Gold); - writer.WriteDeltaTime(ExpireTime); + writer.WriteAnchoredTime(ExpireTime); } private class ExpireTimer : Timer diff --git a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs index 14af3656a..99d1446e6 100644 --- a/Projects/UOContent/Mobiles/Vendors/VendorItem.cs +++ b/Projects/UOContent/Mobiles/Vendors/VendorItem.cs @@ -32,18 +32,20 @@ public partial class VendorItem public string FormattedPrice => Core.ML ? Price.ToString("N0", CultureInfo.GetCultureInfo("en-US")) : Price.ToString(); - [SerializableProperty(2)] - public string Description - { - get => _description; - set - { - _description = value ?? ""; + [SerializableField(2, fieldChanged: nameof(OnDescriptionChanged), allowFieldChange: nameof(AllowDescriptionChange))] + private string _description; - if (Valid) - { - Item.InvalidateProperties(); - } + private bool AllowDescriptionChange(ref string value) + { + value = value ?? ""; + return true; + } + + private void OnDescriptionChanged(string oldValue, string newValue) + { + if (Valid) + { + Item.InvalidateProperties(); } } diff --git a/Projects/UOContent/Multis/Boats/BaseBoat.cs b/Projects/UOContent/Multis/Boats/BaseBoat.cs index 2082056da..ed618b91a 100644 --- a/Projects/UOContent/Multis/Boats/BaseBoat.cs +++ b/Projects/UOContent/Multis/Boats/BaseBoat.cs @@ -19,9 +19,24 @@ namespace Server.Multis Single } - [SerializationGenerator(4, false)] + [SerializationGenerator(5, false)] public abstract partial class BaseBoat : BaseMulti { + private void MigrateFrom(V4Content content) + { + _mapItem = content.MapItem; + _nextNavPoint = content.NextNavPoint; + _facing = content.Facing; + _timeOfDecay = content.TimeOfDecay; + _owner = content.Owner; + _pPlank = content.PPlank; + _sPlank = content.SPlank; + _tillerMan = content.TillerMan; + _hold = content.Hold; + _anchored = content.Anchored; + _shipName = content.ShipName; + } + public enum DryDockResult { Valid, @@ -136,31 +151,23 @@ namespace Server.Multis } } - [DeltaDateTime] - [SerializableProperty(3)] - [CommandProperty(AccessLevel.GameMaster)] - public DateTime TimeOfDecay + [SerializableField(3, fieldChanged: nameof(OnTimeOfDecayChanged))] + [AnchoredDateTime] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private DateTime _timeOfDecay; + + private void OnTimeOfDecayChanged(DateTime oldValue, DateTime newValue) { - get => _timeOfDecay; - set - { - _timeOfDecay = value; - TillerMan?.InvalidateProperties(); - this.MarkDirty(); - } + TillerMan?.InvalidateProperties(); } - [SerializableProperty(10)] - [CommandProperty(AccessLevel.GameMaster)] - public string ShipName + [SerializableField(10, fieldChanged: nameof(OnShipNameChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private string _shipName; + + private void OnShipNameChanged(string oldValue, string newValue) { - get => _shipName; - set - { - _shipName = value; - TillerMan?.InvalidateProperties(); - this.MarkDirty(); - } + TillerMan?.InvalidateProperties(); } [CommandProperty(AccessLevel.GameMaster)] diff --git a/Projects/UOContent/Multis/Camps/BaseCamp.cs b/Projects/UOContent/Multis/Camps/BaseCamp.cs index e3d10dd0b..e60e3e46e 100644 --- a/Projects/UOContent/Multis/Camps/BaseCamp.cs +++ b/Projects/UOContent/Multis/Camps/BaseCamp.cs @@ -6,9 +6,16 @@ using Server.Mobiles; namespace Server.Multis; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public abstract partial class BaseCamp : BaseMulti { + private void MigrateFrom(V1Content content) + { + _items = content.Items; + _mobiles = content.Mobiles; + _decayTime = content.DecayTime; + } + [Tidy] [SerializableField(0, setter: "private")] private List _items; @@ -17,7 +24,7 @@ public abstract partial class BaseCamp : BaseMulti [SerializableField(1, setter: "private")] private List _mobiles; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2, setter: "private")] private DateTime _decayTime; diff --git a/Projects/UOContent/Multis/ComponentVerification.cs b/Projects/UOContent/Multis/ComponentVerification.cs index 967fed1bc..dda094aa4 100644 --- a/Projects/UOContent/Multis/ComponentVerification.cs +++ b/Projects/UOContent/Multis/ComponentVerification.cs @@ -4,11 +4,14 @@ using System.Collections.Generic; using System.IO; using System.IO.Compression; using Server.Compression; +using Server.Logging; namespace Server.Multis; public static class ComponentVerification { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ComponentVerification)); + private static int[] _itemTable; private static int[] _multiTable; private static bool _loaded; @@ -20,6 +23,15 @@ public static class ComponentVerification // encodes them) while AOS/SE/ML/... line up unchanged. private const int HousingTierMask = (int)HousingFlags.HousingEJ; + // Table sentinels. Slots start as NotAComponent, which CheckValidity rejects, so a piece that + // never gets registered can never be placed. NoFeatureRequired is a piece with no expansion + // requirement: walls.txt encodes pre-AOS base pieces as 0, housing.bin collapses to 0 under + // HousingTierMask. + private const int NotAComponent = -1; + private const int NoFeatureRequired = 0; + + private const string FeatureMaskColumn = "FeatureMask"; + public static bool IsItemValid(int itemID) { EnsureLoaded(); @@ -33,7 +45,8 @@ public static class ComponentVerification } private static bool CheckValidity(int val) => - val != -1 && (val == 0 || ((int)ExpansionInfo.CoreExpansion.HousingFlags & val) != 0); + val != NotAComponent && + (val == NoFeatureRequired || ((int)ExpansionInfo.CoreExpansion.HousingFlags & val) != 0); private static void EnsureLoaded() { @@ -42,22 +55,50 @@ public static class ComponentVerification return; } + // Set before loading: this runs from the design packet handler, so a bad file must not + // re-read every sheet on each later placement attempt. Sheets below fail independently. _loaded = true; _itemTable = CreateTable(TileData.MaxItemValue); _multiTable = CreateTable(0x4000); var housingPath = MultiData.HousingUOPPath; - if (housingPath != null) + if (housingPath != null && TryLoadFromHousingBin(housingPath)) { - var entry = MultiData.HousingEntry; - LoadFromHousingBin(ReadUOPEntry(housingPath, entry)); return; } LoadFromTxtFiles(); } + private static bool TryLoadFromHousingBin(string path) + { + try + { + var data = ReadUOPEntry(path, MultiData.HousingEntry); + if (data != null) + { + LoadFromHousingBin(data); + return true; + } + + logger.Warning( + "Could not decompress housing.bin from {Path}. Falling back to the component sheets", + path + ); + } + catch (Exception ex) + { + logger.Warning( + ex, + "Failed to read housing.bin from {Path}. Falling back to the component sheets", + path + ); + } + + return false; + } + private static byte[] ReadUOPEntry(string path, UOPEntry entry) { using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); @@ -223,16 +264,55 @@ public static class ComponentVerification return; } - var ss = new Spreadsheet(path); + Spreadsheet ss; + try + { + ss = new Spreadsheet(path); + } + catch (Exception ex) + { + // One unreadable sheet must not take the others down with it. + logger.Error(ex, "Could not read house components from {Path}", path); + return; + } + + // GetInt32 on a missing column yields NoFeatureRequired, which would register every piece in + // the sheet as unconditionally placeable. Refuse the sheet instead. + var featureCID = ss.GetColumnID(FeatureMaskColumn); + if (featureCID < 0) + { + logger.Error( + "House component sheet {Path} has no {Column} column. Its pieces will not be registered", + path, + FeatureMaskColumn + ); + return; + } + + // An individual missing column is expected - older sheets predate walls.txt's + // SecondAltWindowS/E - but a sheet matching none of them is not the sheet we expect. var tileCIDs = new int[tileColumns.Length]; + var matchedColumns = 0; for (var i = 0; i < tileColumns.Length; ++i) { tileCIDs[i] = ss.GetColumnID(tileColumns[i]); + + if (tileCIDs[i] >= 0) + { + matchedColumns++; + } } - var featureCID = ss.GetColumnID("FeatureMask"); + if (matchedColumns == 0) + { + logger.Error( + "House component sheet {Path} has none of its expected tile columns. Its pieces will not be registered", + path + ); + return; + } for (var i = 0; i < ss.Records.Length; ++i) { @@ -260,7 +340,7 @@ public static class ComponentVerification for (var i = 0; i < table.Length; ++i) { - table[i] = -1; + table[i] = NotAComponent; } return table; @@ -277,11 +357,18 @@ public class Spreadsheet var types = ReadLine(ip); var names = ReadLine(ip); + if (types == null || names == null) + { + throw new InvalidDataException($"House component sheet '{path}' is missing its header rows."); + } + m_Columns = new ColumnInfo[types.Length]; for (var i = 0; i < m_Columns.Length; ++i) { - m_Columns[i] = new ColumnInfo(i, types[i], names[i]); + // A names row shorter than the types row leaves the extras unnamed, so nothing resolves + // to them. + m_Columns[i] = new ColumnInfo(i, types[i], i < names.Length ? names[i] : ""); } var records = new List(); @@ -294,10 +381,15 @@ public class Spreadsheet { var ci = m_Columns[i]; + // Client sheets write an empty trailing Comment as a plain newline, leaving the row + // one field short. The client only requires the columns up to FeatureMask, so treat + // the missing field as empty rather than dropping a row that lists real pieces. + var value = ci.m_DataIndex < values.Length ? values[ci.m_DataIndex] : null; + data[i] = ci.m_Type switch { - "int" => Utility.ToInt32(values[ci.m_DataIndex]), - "string" => values[ci.m_DataIndex], + "int" => Utility.ToInt32(value), + "string" => value, _ => data[i] }; } @@ -327,7 +419,10 @@ public class Spreadsheet { while (ip.ReadLine() is { } line) { - if (line.Length > 0) + // Whitespace-only, not merely empty: the retail client's doors.txt separates its header + // rows with lines of bare tabs, and accepting one as the names row leaves every column + // unnamed, so no door resolves. The client skips them the same way. + if (!string.IsNullOrWhiteSpace(line)) { return line.Split('\t'); } diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs new file mode 100644 index 000000000..7b3c44ae4 --- /dev/null +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs @@ -0,0 +1,343 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: AutoDenylist.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Server.Logging; +using Server.Network.Bans; + +namespace Server.Network; + +/// +/// A short-lived, in-memory denylist of addresses the shard itself just caught misbehaving. +/// +/// +/// The local half of promotion. Contributing to CrowdSec only helps once an OS bouncer reacts; until then +/// every reconnect costs a socket, a buffer and a NetState slot — and the verdicts that matter most +/// are reachable only after reading bytes, like a zero seed. It is also the whole defense on a shard running +/// no bouncer, which is the default. Not persisted, by design: a holding pen that survives restarts is a ban +/// without a ban's review. Only verdicts are held. +/// +/// +/// A hold is never refreshed, so every expiry is insertion + duration and the ring is sorted by +/// construction. Retiring lapsed entries is therefore the number expiring rather than the number held, which +/// is what lets the cap be sized for the flood instead of for a scan. +/// +public static class AutoDenylist +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoDenylist)); + + // Membership only (normalized v6 bits). Loop-only. The expiry lives beside the key in the ring, so + // there is exactly one copy of it and the two cannot disagree. + private static readonly HashSet _held = []; + + // The same keys in expiry order. Parallel arrays rather than an array of structs: UInt128 forces + // 16-byte alignment, so a packed (key, expiry) struct costs 32 bytes where these cost 24 -- and the + // drain reads only the long[], 8 sequential bytes per entry. + private static UInt128[] _ringKeys = []; + private static long[] _ringExpiry = []; + private static int _ringHead; + private static int _ringCount; + + private static bool _enabled; + private static long _durationMs; + private static int _maxEntries; + private static bool _warnedFull; + + public static int Count => _held.Count; + + // Test seam: the ring and the set hold the same entries, and nothing else may assume it. + internal static int RingCount => _ringCount; + + public static void Configure() + { + AutoDenylistConfiguration.Load(); + var s = AutoDenylistConfiguration.Settings; + + _enabled = s.Enabled && s.Duration > TimeSpan.Zero && s.MaxEntries > 0; + if (!_enabled) + { + return; + } + + _durationMs = (long)s.Duration.TotalMilliseconds; + _maxEntries = s.MaxEntries; + + ConnectionFilters.Register(new AutoDenylistFilter()); + BanChannel.Register(new AutoDenylistReporter()); + } + + /// + /// Holds an address for the configured duration. Ignores non-behavioural verdicts and refuses to grow + /// past the cap: the flood this exists for must not become a memory leak. + /// + public static void Hold(IPAddress address, string reason) => Hold(address, reason, Core.TickCount); + + internal static bool Hold(IPAddress address, string reason, long nowTicks) + { + if (!_enabled || address == null || !BanReasons.IsBehavioral(reason)) + { + return false; + } + + Drain(nowTicks); + + var key = address.ToUInt128(); + + // Deliberately not refreshed: the first detection sets the expiry and later ones leave it alone. + // That keeps insertion order equal to expiry order, which is why the drain can stop at the first + // live record. A flooder whose hold lapses trips the rate limiter on its next attempt -- which + // runs ahead of the connection filters -- and is held again. + if (!_held.Add(key)) + { + return true; + } + + // Drain already reclaimed everything reclaimable, so being over now means genuinely full. + if (_held.Count > _maxEntries) + { + _held.Remove(key); + + if (!_warnedFull) + { + _warnedFull = true; + logger.Warning( + "Auto-denylist is full at {Max} addresses; further detections are disconnected but not held", + _maxEntries + ); + } + + return false; + } + + Push(key, nowTicks + _durationMs); + return true; + } + + public static bool IsDenied(IPAddress address) => IsDenied(address, Core.TickCount); + + /// + /// The accept-path decision, split out so the policy can be tested without a clock. Drains first: the + /// expiry lives in the ring, not beside the membership, so a lapsed hold has to be retired here rather + /// than expired on read. One array read when nothing has lapsed. + /// + internal static bool IsDenied(IPAddress address, long nowTicks) + { + if (!_enabled || address == null) + { + return false; + } + + Drain(nowTicks); + return _held.Contains(address.ToUInt128()); + } + + /// Releases an address early, e.g. when an operator retracts a ban. + public static void Release(IPAddress address) + { + if (!_enabled || address == null) + { + return; + } + + var key = address.ToUInt128(); + if (_held.Remove(key)) + { + // The ring record has to go too. Nothing records that this key was released, so if it were + // detected again before the old record lapsed, that record would retire the new hold early. + // O(n), but this is an operator retraction, not the accept path. + PurgeRing(key); + } + } + + /// + /// Retires everything that has lapsed. Expiries only ever increase along the ring, so the first live + /// record ends the scan and the cost is the number actually expiring, not the number held. + /// + internal static void Drain(long nowTicks) + { + var before = _ringCount; + + // Subtraction, never a direct compare: tick counts wrap. See dev-docs/tick-counts.md. + while (_ringCount > 0 && _ringExpiry[_ringHead] - nowTicks <= 0) + { + _held.Remove(_ringKeys[_ringHead]); + _ringHead = _ringHead + 1 == _ringKeys.Length ? 0 : _ringHead + 1; + _ringCount--; + } + + if (_ringCount != before) + { + _warnedFull = false; + } + } + + private static void Push(UInt128 key, long expiry) + { + if (_ringCount == _ringKeys.Length) + { + Grow(); + } + + var tail = _ringHead + _ringCount; + if (tail >= _ringKeys.Length) + { + tail -= _ringKeys.Length; + } + + _ringKeys[tail] = key; + _ringExpiry[tail] = expiry; + _ringCount++; + } + + private static void Grow() + { + // Capped at the entry cap: Push only runs below it, so the ring never needs more, and doubling + // past it would reserve roughly twice the slots it can ever use. + var size = Math.Min(Math.Max(64, _ringKeys.Length * 2), _maxEntries); + var keys = new UInt128[size]; + var expiry = new long[size]; + + for (var i = 0; i < _ringCount; i++) + { + var from = _ringHead + i; + if (from >= _ringKeys.Length) + { + from -= _ringKeys.Length; + } + + keys[i] = _ringKeys[from]; + expiry[i] = _ringExpiry[from]; + } + + _ringKeys = keys; + _ringExpiry = expiry; + _ringHead = 0; + } + + private static void PurgeRing(UInt128 key) + { + var capacity = _ringKeys.Length; + + for (var i = 0; i < _ringCount; i++) + { + var at = _ringHead + i; + if (at >= capacity) + { + at -= capacity; + } + + if (_ringKeys[at] != key) + { + continue; + } + + // Close the gap so the ring stays contiguous and expiry-ordered. + for (var j = i; j < _ringCount - 1; j++) + { + var to = _ringHead + j; + if (to >= capacity) + { + to -= capacity; + } + + var from = to + 1 == capacity ? 0 : to + 1; + _ringKeys[to] = _ringKeys[from]; + _ringExpiry[to] = _ringExpiry[from]; + } + + _ringCount--; + return; + } + } + + internal static void LoadForTesting(bool enabled, long durationMs, int maxEntries) + { + _held.Clear(); + _ringKeys = []; + _ringExpiry = []; + _ringHead = 0; + _ringCount = 0; + _enabled = enabled; + _durationMs = durationMs; + _maxEntries = maxEntries; + _warnedFull = false; + } +} + +/// Accept-path gate for . +public sealed class AutoDenylistFilter : IConnectionFilter +{ + private Timer _sweepTimer; + + public string Name => "auto-denylist"; + + public void Register() + { + } + + public void Start(CancellationToken token) + { + // Only reclaims memory: Hold and IsDenied both drain, so this matters on a shard that has gone + // quiet after a flood and would otherwise hold the ring until someone next connects. + _sweepTimer = Timer.DelayCall( + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(1), + () => AutoDenylist.Drain(Core.TickCount) + ); + } + + public void Stop() + { + // Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one. + _sweepTimer?.Stop(); + _sweepTimer = null; + } + + public bool ShouldDeny(IPAddress address) => AutoDenylist.IsDenied(address); +} + +/// +/// Feeds from the ban channel. A reporter rather than a direct call, because the +/// detection sites live in the engine and must not reach into content. +/// +public sealed class AutoDenylistReporter : IBanReporter +{ + public string Name => "auto-denylist"; + + public bool CanRetract => true; + + public void Register() + { + } + + public void Start(CancellationToken token) + { + } + + public void Stop() + { + } + + /// + /// The contributed is ignored: how long a bouncer should ban an address is a + /// different question from how long this shard holds it at accept. + /// + public void Report(IPAddress address, TimeSpan ttl, string reason) => AutoDenylist.Hold(address, reason); + + public void Retract(IPAddress address) => AutoDenylist.Release(address); +} diff --git a/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs new file mode 100644 index 000000000..fd13ef003 --- /dev/null +++ b/Projects/UOContent/Network/AutoDenylist/AutoDenylistConfiguration.cs @@ -0,0 +1,88 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: AutoDenylistConfiguration.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network; + +/// +/// Loads the from Configuration/auto-denylist.json. Loaded once; +/// a missing file writes a template so operators have something to edit. +/// +public static class AutoDenylistConfiguration +{ + private const string _path = "Configuration/auto-denylist.json"; + + public static AutoDenylistSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new AutoDenylistSettings(); + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// Bound configuration for . +public record AutoDenylistSettings +{ + /// Whether behavioural detections are held locally. Disabled makes the filter inert. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + /// + /// How long an address is denied at accept after the shard catches it misbehaving. Deliberately + /// independent of the duration reported to external bouncers: this is a local holding pen, not a ban. + /// + /// + /// Short on purpose: it covers the gap before an OS bouncer reacts, and blunts a flood on shards running + /// none. An address still attacking is simply re-detected and re-added, so the list sustains itself while + /// a mistake clears on its own. + /// + [JsonPropertyName("duration")] + public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(15); + + /// + /// Hard cap on tracked addresses: a distinct-source flood is the case this exists for, so the cap is what + /// stops it becoming the exhaustion it prevents. At the cap new addresses are not tracked, but are still + /// disconnected by whichever gate detected them. + /// + /// + /// A HashSet capacity, not a round number. Grown from empty it steps 36,353 → 75,431 → 156,437 + /// → 324,449, so this fills one exactly instead of stranding slots: 65,536 sat just past a resize and + /// left 9,895 of them unusable. Sized to cover the 50k–250k distinct-source floods seen in practice, + /// for ~19 MB — 36 bytes a set slot plus 24 for the ring record. Raising it is bounded by memory + /// rather than by a scan, since holds are retired from the ring in expiry order; a flood past it wants + /// upstream scrubbing rather than a larger cap. + /// + [JsonPropertyName("maxEntries")] + public int MaxEntries { get; set; } = 324_449; +} diff --git a/Projects/UOContent/Network/BanExemptions.cs b/Projects/UOContent/Network/BanExemptions.cs new file mode 100644 index 000000000..c5f6b726a --- /dev/null +++ b/Projects/UOContent/Network/BanExemptions.cs @@ -0,0 +1,61 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BanExemptions.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Net; +using Server.Network.Bans; + +namespace Server.Network; + +/// +/// Combines and into the one answer +/// asks for, so neither source has to know about the other. +/// +public static class BanExemptions +{ + public static void Configure() + { + BanChannel.IsExempt = IsExempt; + } + + public static bool IsExempt(IPAddress address, string reason) => + IsExempt(address, reason, LoginAllowlist.IsExemptFromEscalation); + + /// + /// Split for testing. is stateful — calling it spends a strike — so it + /// must not be invoked once the answer is already decided. + /// + internal static bool IsExempt(IPAddress address, string reason, Func loginAllowlist) + { + if (address == null) + { + return false; + } + + // Never suppress an operator's explicit ban. Checked first so it also costs no strike. + if (!BanReasons.IsBehavioral(reason)) + { + return false; + } + + // Deliberate and unconditional, so it wins and must not spend the earned list's strikes. + if (ManualAllowlist.Contains(address)) + { + return true; + } + + return loginAllowlist(address, reason); + } +} diff --git a/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs new file mode 100644 index 000000000..19541636f --- /dev/null +++ b/Projects/UOContent/Network/Blocklist/BlocklistConfiguration.cs @@ -0,0 +1,96 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistConfiguration.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans; + +/// +/// Loads the from Configuration/blocklist.json. Loaded once; a +/// missing file writes a template so operators have something to edit. +/// +public static class BlocklistConfiguration +{ + private const string _path = "Configuration/blocklist.json"; + + public static BlocklistSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new BlocklistSettings(); + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for . The filter is inert unless +/// is set and points at a list that exists, so a shard that never runs the generator +/// pays nothing for the defaults. +/// +public record BlocklistSettings +{ + /// + /// Whether the accept-path gate runs at all. Off by default: the reload poll runs for the whole + /// uptime, which no shard should pay before an operator has chosen to run a blocklist. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + /// + /// Path to the blocklist. A relative path resolves against ; an + /// absolute path is used as-is (handy when several shards share one generated list). Set to + /// "" to disable the gate entirely. Produce the file with tools/Export-IpBlocklist.ps1. + /// + [JsonPropertyName("file")] + public string File { get; set; } = "Configuration/ip-blocklist.txt"; + + /// How often the file is checked for changes. Reloads only happen when it actually changed. + [JsonPropertyName("reloadInterval")] + public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); + + /// Whether blocklist hits are contributed to the ban channel (the demand-paging promotion). + [JsonPropertyName("reportHits")] + public bool ReportHits { get; set; } = true; + + /// Duration reported for a blocklist-matched ban. + [JsonPropertyName("banDuration")] + public TimeSpan BanDuration { get; set; } = TimeSpan.FromHours(6); + + /// + /// How long the accept-path guard suppresses re-reporting a promoted address. This only needs to + /// bridge the gap until the OS bouncer picks up the promotion (seconds); after that the kernel drops + /// repeat traffic. Decoupled from so the guard doesn't have to remember + /// hours' worth of distinct addresses. + /// + [JsonPropertyName("promoteSuppression")] + public TimeSpan PromoteSuppression { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/Projects/UOContent/Network/Blocklist/BlocklistFile.cs b/Projects/UOContent/Network/Blocklist/BlocklistFile.cs new file mode 100644 index 000000000..a593a7035 --- /dev/null +++ b/Projects/UOContent/Network/Blocklist/BlocklistFile.cs @@ -0,0 +1,86 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistFile.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; + +namespace Server.Network.Bans; + +public readonly record struct BlocklistHeader(string Generated, int Count, bool Present); + +/// Reads the versioned blocklist file: cheap header probe + full snapshot load. +public static class BlocklistFile +{ + public static bool TryReadHeader(string path, out BlocklistHeader header) + { + header = new BlocklistHeader(null, 0, false); + try + { + if (!File.Exists(path)) + { + return false; + } + using var reader = new StreamReader(path); + var first = reader.ReadLine(); + if (first == null) + { + header = new BlocklistHeader(null, 0, true); + return true; + } + string generated = null; + var count = 0; + if (first.StartsWith('#')) + { + var tokens = first.Split(' ', StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < tokens.Length; i++) + { + var tok = tokens[i]; + if (tok.StartsWith("generated=", StringComparison.Ordinal)) + { + generated = tok["generated=".Length..]; + } + else if (tok.StartsWith("count=", StringComparison.Ordinal)) + { + int.TryParse(tok["count=".Length..], out count); + } + } + } + header = new BlocklistHeader(generated, count, true); + return true; + } + catch + { + return false; // treat as absent; caller keeps last-good / empty + } + } + + public static BlocklistSnapshot Load(string path, out int parsed, out int skipped) + { + parsed = 0; + skipped = 0; + try + { + if (!File.Exists(path)) + { + return BlocklistSnapshot.Empty; + } + return BlocklistSnapshot.Build(File.ReadAllBytes(path), out parsed, out skipped); + } + catch + { + return BlocklistSnapshot.Empty; + } + } +} diff --git a/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs new file mode 100644 index 000000000..030997803 --- /dev/null +++ b/Projects/UOContent/Network/Blocklist/BlocklistFilter.cs @@ -0,0 +1,316 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistFilter.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Server.Logging; + +namespace Server.Network.Bans; + +/// +/// Accept-path gate for a large, file-sourced IP blocklist, hydrated from the file a generator +/// (tools/Export-IpBlocklist.ps1) writes on a schedule. Holds an immutable snapshot swapped +/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Opt-in via +/// blocklist.json's enabled; inert when off, or when no file is configured or present. +/// +/// +/// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on +/// Windows, so the millions live here and only addresses that actually connect are promoted to CrowdSec +/// (and from there to the OS firewall) through . +/// keeps a flood of repeat connections from re-reporting the same address before the bouncer picks it up. +/// +public sealed class BlocklistFilter : IConnectionFilter +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(BlocklistFilter)); + + // Written by the reload poll (off-loop), read by the accept path (game loop). One volatile reference + // swap is the whole synchronization story: readers see the old or the new snapshot, whole. + private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; + + private readonly PromotedGuard _guard = new(); + + private bool _enabled; + private string _path; + private TimeSpan _interval; + private bool _reportHits; + private TimeSpan _banDuration; + private long _suppressionMs; + private string _lastGenerated; + private DateTime _lastWriteUtc; + private CancellationTokenSource _cts; + private Timer _sweepTimer; + + public string Name => "blocklist"; + + public int Count => _snapshot.Count; + + public static void Configure() + { + ConnectionFilters.Register(new BlocklistFilter()); + } + + public void Register() + { + BlocklistConfiguration.Load(); + var s = BlocklistConfiguration.Settings; + + _path = ResolvePath(s.File); + _enabled = s.Enabled && _path != null; + _interval = s.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.ReloadInterval; + _reportHits = s.ReportHits; + _banDuration = s.BanDuration; + _suppressionMs = (long)s.PromoteSuppression.TotalMilliseconds; + } + + /// + /// Resolves the configured path once: a relative path is anchored to + /// (never the process working directory, which differs when the shard is launched from elsewhere), an + /// absolute path is taken as-is so several shards can share one generated list. + /// + private static string ResolvePath(string configured) + { + if (string.IsNullOrWhiteSpace(configured)) + { + return null; + } + + return Path.IsPathRooted(configured) ? configured : Path.Join(Core.BaseDirectory, configured); + } + + public void Start(CancellationToken token) + { + if (!_enabled) + { + LogWhyDisabled(); + return; + } + + // The operator's override on this gate, opted into separately. Without it only the generator's + // subtraction covers carve-outs, and that does not cover ban contributions. + if (!ManualAllowlist.Enabled) + { + logger.Warning( + "Blocklist is on but the manual allowlist is not; set \"enabled\" in ip-allowlist.json so a " + + "carve-out also suppresses ban contributions" + ); + } + + _cts = CancellationTokenSource.CreateLinkedTokenSource(token); + + // A missing file is not an error: the gate stays inert until the poll picks up whatever the + // generator first writes. No restart needed. + if (File.Exists(_path)) + { + Reload(); // synchronous prime; empty on failure (fail-open) + } + else + { + logger.Information("Blocklist inert: no list at \"{Path}\"; polling every {Interval}", _path, _interval); + } + + // Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. Only marked when hits + // are reported, so there is nothing to sweep otherwise. + if (_reportHits) + { + _sweepTimer = Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard); + } + + _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token); + } + + private void LogWhyDisabled() + { + if (_path == null) + { + logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)"); + } + else if (File.Exists(_path)) + { + // An upgraded shard has a list on disk but no "enabled" key, so say so rather than silently + // dropping a gate it was relying on. + logger.Warning( + "Blocklist is off (\"enabled\" false in blocklist.json) but a list is present at \"{Path}\"; " + + "no addresses will be denied", + _path + ); + } + else + { + logger.Information("Blocklist disabled (\"enabled\" false in blocklist.json)"); + } + } + + public void Stop() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + + // Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one. + _sweepTimer?.Stop(); + _sweepTimer = null; + } + + public bool ShouldDeny(IPAddress address) + { + if (!Evaluate(address, Core.TickCount, out var shouldReport)) + { + return false; + } + + if (shouldReport) + { + // Demand-page this address up to the OS-level bouncer. Enqueue-only; never blocks the loop. + BanChannel.Report(address, _banDuration, BanReasons.Blocklist); + } + + return true; + } + + /// + /// The pure decision, split out so the accept-path policy can be tested without a clock or a ban + /// channel. is true at most once per suppression window. + /// + internal bool Evaluate(IPAddress address, long nowTicks, out bool shouldReport) + { + shouldReport = false; + + if (!_snapshot.IsBanned(address)) + { + return false; + } + + // Both are asked only once the list has matched, so they cost the common accept nothing. The file + // list is usually redundant because the generator subtracts it — except right after an operator + // adds an entry without regenerating, which is when someone is waiting to get back in. + if (ManualAllowlist.Contains(address)) + { + return false; + } + + // A feed listing an address a real player logged in from recently is far more often a false positive + // than a compromise. + if (LoginAllowlist.IsAllowed(address)) + { + return false; + } + + if (_reportHits) + { + shouldReport = _guard.TryMark(address.ToUInt128(), nowTicks, _suppressionMs); + } + + return true; + } + + private void SweepGuard() => _guard.Sweep(Core.TickCount); + + // Test hook: inject a snapshot and policy without file I/O. + internal void LoadForTesting(BlocklistSnapshot snapshot, bool reportHits = true, long suppressionMs = 60000) + { + _snapshot = snapshot; + _reportHits = reportHits; + _suppressionMs = suppressionMs; + } + + private async ValueTask PollLoop(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(_interval, token); + } + catch (OperationCanceledException) + { + return; + } + + try + { + if (ChangedSinceLastLoad()) + { + // Parsing millions of lines competes with a save for CPU and page cache, so yield until + // the world is written out. See the threading policy in CLAUDE.md (rules #3 and #10). + while (World.Saving || World.WorldState == WorldState.PendingSave) + { + await Task.Delay(TimeSpan.FromSeconds(1), token); + } + + Reload(); + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) + { + logger.Warning(e, "Blocklist reload check failed; keeping last snapshot ({Count})", Count); + } + } + } + + private bool ChangedSinceLastLoad() + { + try + { + var info = new FileInfo(_path); + if (!info.Exists) + { + return false; + } + + if (info.LastWriteTimeUtc == _lastWriteUtc) + { + return false; // cheapest guard + } + } + catch + { + return false; + } + + return !BlocklistFile.TryReadHeader(_path, out var h) || h.Generated != _lastGenerated; + } + + private void Reload() + { + // Capture the mtime/header BEFORE Load() so they describe the version being parsed. Capturing + // after could skip a version the producer swapped in mid-parse; stale markers only cost a reload. + var writeUtc = default(DateTime); + try + { + writeUtc = new FileInfo(_path).LastWriteTimeUtc; + } + catch + { + /* keep default */ + } + + BlocklistFile.TryReadHeader(_path, out var h); + + var next = BlocklistFile.Load(_path, out var parsed, out var skipped); + _snapshot = next; // single volatile swap; readers see old or new whole + _lastGenerated = h.Generated; + _lastWriteUtc = writeUtc; + + logger.Information("Blocklist loaded {Parsed} entr(ies) ({Count} ranges, {Skipped} skipped) gen={Gen}", + parsed, next.Count, skipped, h.Generated); + } +} diff --git a/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs new file mode 100644 index 000000000..d62df0752 --- /dev/null +++ b/Projects/UOContent/Network/Blocklist/BlocklistSnapshot.cs @@ -0,0 +1,210 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: BlocklistSnapshot.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Buffers.Text; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Server.Collections; + +namespace Server.Network.Bans; + +/// +/// Immutable dual-stack blocklist. Singles and CIDRs are folded into a single sorted, coalesced +/// interval index per family: IPv4 as ranges (lean for the millions-strong common +/// case), IPv6 as ranges (empty unless the feed carries v6). Immutable → lock-free reads. +/// +public sealed class BlocklistSnapshot +{ + public static readonly BlocklistSnapshot Empty = new(SortedRangeIndex.Empty, SortedRangeIndex.Empty); + + private readonly SortedRangeIndex _v4; + private readonly SortedRangeIndex _v6; + + public int Count => _v4.Count + _v6.Count; + + private BlocklistSnapshot(SortedRangeIndex v4, SortedRangeIndex v6) + { + _v4 = v4; + _v6 = v6; + } + + /// + /// Parses a blocklist directly from its UTF-8/ASCII file bytes — one line at a time, splitting on + /// '\n' with no per-line string allocation. IPv4 singles and CIDRs are parsed straight from the + /// byte span; IPv6 (the rare path) decodes the single address token and defers to the framework parser. + /// Malformed lines increment and never throw. + /// + public static BlocklistSnapshot Build(ReadOnlySpan data, out int parsed, out int skipped) + { + parsed = 0; + skipped = 0; + + // Only the two final index arrays (allocated inside SortedRangeIndex.Build) hit the heap; every + // build-time buffer here is a pooled ref list. mt: true is required — this runs off the game loop. + using var v4 = PooledRefList.Range>.Create(mt: true); + using var v6 = PooledRefList.Range>.Create(mt: true); + + var rest = data; + while (!rest.IsEmpty) + { + ReadOnlySpan line; + var nl = rest.IndexOf((byte)'\n'); + if (nl >= 0) + { + line = rest[..nl]; + rest = rest[(nl + 1)..]; + } + else + { + line = rest; + rest = default; + } + + line = line[Ascii.Trim(line)]; + if (line.IsEmpty || line[0] == (byte)'#' || line[0] == (byte)';') + { + continue; + } + + var slash = line.IndexOf((byte)'/'); + var addr = slash >= 0 ? line[..slash] : line; + var bitsToken = slash >= 0 ? line[(slash + 1)..] : default; + + if (addr.IndexOf((byte)':') < 0) + { + // IPv4 single or CIDR — parsed straight from the byte span. + if (slash >= 0) + { + if (IPAddressUtility.TryParseV4(addr, out var ip) && + TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 32) + { + var size = bits == 0 ? 0xFFFFFFFFu : (1u << (32 - bits)) - 1; + var b = ip & ~size; + v4.Add(new SortedRangeIndex.Range(b, b + size)); + parsed++; + } + else + { + skipped++; + } + } + else if (IPAddressUtility.TryParseV4(addr, out var ip)) + { + v4.Add(new SortedRangeIndex.Range(ip, ip)); + parsed++; + } + else + { + skipped++; + } + } + else if (TryDecodeV6(addr, out var v)) + { + // IPv6 is rare in these feeds; the single token was decoded and framework-parsed above. + if (slash >= 0) + { + if (TryParseBits(bitsToken, out var bits) && bits is >= 0 and <= 128) + { + var mask = bits == 0 ? UInt128.Zero : ~((UInt128.One << (128 - bits)) - 1); + var b = v & mask; + v6.Add(new SortedRangeIndex.Range(b, b | ~mask)); + parsed++; + } + else + { + skipped++; + } + } + else + { + v6.Add(new SortedRangeIndex.Range(v, v)); + parsed++; + } + } + else + { + skipped++; + } + } + + v4.Sort(SortedRangeIndex.ByMin); + v6.Sort(SortedRangeIndex.ByMin); + return new BlocklistSnapshot(SortedRangeIndex.Build(v4.AsSpan()), SortedRangeIndex.Build(v6.AsSpan())); + } + + // Decodes a single IPv6 address token from ASCII bytes and validates it via the framework parser. + private static bool TryDecodeV6(ReadOnlySpan addr, out UInt128 v) + { + v = UInt128.Zero; + if (addr.Length > 45) + { + return false; + } + + Span chars = stackalloc char[addr.Length]; + for (var i = 0; i < addr.Length; i++) + { + chars[i] = (char)addr[i]; + } + + if (!IPAddress.TryParse(chars, out var a) || a.AddressFamily != AddressFamily.InterNetworkV6) + { + return false; + } + + v = a.ToUInt128(); + return true; + } + + private static bool TryParseBits(ReadOnlySpan token, out int bits) + { + if (Utf8Parser.TryParse(token, out bits, out var consumed) && consumed == token.Length) + { + return true; + } + + bits = 0; + return false; + } + + /// + /// Plain set membership, for callers whose set is an ALLOWlist (see ) and for + /// whom would read backwards. The interval machinery is direction-agnostic. + /// + public bool Contains(IPAddress ip) => IsBanned(ip); + + public bool IsBanned(IPAddress ip) + { + if (ip.IsIPv4MappedToIPv6) + { + // v6-encoded v4 must not dodge the v4 set; extract the embedded v4 uint directly. + return IPAddressUtility.TryMappedV4(ip, out var mv) && _v4.Contains(mv); + } + + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + return IPAddressUtility.TryV4(ip, out var v) && _v4.Contains(v); + } + + if (ip.AddressFamily == AddressFamily.InterNetworkV6) + { + return _v6.Contains(ip.ToUInt128()); + } + + return false; + } +} diff --git a/Projects/UOContent/Network/Blocklist/PromotedGuard.cs b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs new file mode 100644 index 000000000..bba8e3ee5 --- /dev/null +++ b/Projects/UOContent/Network/Blocklist/PromotedGuard.cs @@ -0,0 +1,51 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: PromotedGuard.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; + +namespace Server.Network.Bans; + +/// Suppresses re-reporting the same IP within a TTL. Accept-path thread only. +public sealed class PromotedGuard +{ + private readonly Dictionary _expiry = []; + + public bool TryMark(UInt128 ip, long nowTicks, long ttlMs) + { + if (_expiry.TryGetValue(ip, out var exp) && exp - nowTicks > 0) + { + return false; + } + _expiry[ip] = nowTicks + ttlMs; + return true; + } + + public void Sweep(long nowTicks) + { + if (_expiry.Count == 0) + { + return; + } + + foreach (var (ip, exp) in _expiry) + { + if (exp - nowTicks <= 0) + { + _expiry.Remove(ip); + } + } + } +} diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecAlert.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecAlert.cs new file mode 100644 index 000000000..773cf50ec --- /dev/null +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecAlert.cs @@ -0,0 +1,78 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecAlert.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Text.Json.Serialization; + +namespace Server.Network.Bans.CrowdSec; + +/// One CrowdSec alert (POST /v1/alerts takes an array of these). +public sealed class CrowdSecAlert +{ + [JsonPropertyName("scenario")] public string Scenario { get; set; } + + /// + /// Required by LAPI. It dereferences the field unconditionally when persisting the alert, so + /// omitting it answers 500 rather than a validation error. Empty is the accepted value for a + /// watcher that isn't shipping a hub scenario. + /// + [JsonPropertyName("scenario_hash")] public string ScenarioHash { get; set; } = ""; + + /// Required alongside ; same 500-on-missing behavior. + [JsonPropertyName("scenario_version")] public string ScenarioVersion { get; set; } = "1.0"; + + [JsonPropertyName("message")] public string Message { get; set; } + [JsonPropertyName("events_count")] public int EventsCount { get; set; } = 1; + [JsonPropertyName("start_at")] public string StartAt { get; set; } + [JsonPropertyName("stop_at")] public string StopAt { get; set; } + + /// Bucket capacity. One decision per alert, so 1 — a leaky-bucket capacity of 0 is nonsense. + [JsonPropertyName("capacity")] public int Capacity { get; set; } = 1; + [JsonPropertyName("leakspeed")] public string LeakSpeed { get; set; } = "0s"; + [JsonPropertyName("simulated")] public bool Simulated { get; set; } + [JsonPropertyName("events")] public object[] Events { get; set; } = []; + [JsonPropertyName("remediation")] public bool Remediation { get; set; } = true; + [JsonPropertyName("source")] public CrowdSecSource Source { get; set; } + [JsonPropertyName("decisions")] public CrowdSecDecisionDto[] Decisions { get; set; } +} + +public sealed class CrowdSecSource +{ + [JsonPropertyName("scope")] public string Scope { get; set; } = "Ip"; + [JsonPropertyName("value")] public string Value { get; set; } +} + +public sealed class CrowdSecDecisionDto +{ + [JsonPropertyName("origin")] public string Origin { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } = "ban"; + [JsonPropertyName("scope")] public string Scope { get; set; } = "Ip"; + [JsonPropertyName("value")] public string Value { get; set; } + [JsonPropertyName("duration")] public string Duration { get; set; } + [JsonPropertyName("scenario")] public string Scenario { get; set; } +} + +/// Watcher login request/response for POST /v1/watchers/login. +public sealed class CrowdSecLoginRequest +{ + [JsonPropertyName("machine_id")] public string MachineId { get; set; } + [JsonPropertyName("password")] public string Password { get; set; } +} + +public sealed class CrowdSecLoginResponse +{ + [JsonPropertyName("code")] public int Code { get; set; } + [JsonPropertyName("token")] public string Token { get; set; } + [JsonPropertyName("expire")] public string Expire { get; set; } +} diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecAlertClient.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecAlertClient.cs new file mode 100644 index 000000000..b91d0ebe5 --- /dev/null +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecAlertClient.cs @@ -0,0 +1,152 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecAlertClient.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Server.Network.Bans.CrowdSec; + +/// Reporter-side LAPI operations, mockable for tests. +public interface ICrowdSecAlertClient : IDisposable +{ + ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token); + ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token); +} + +/// +/// CrowdSec LAPI watcher client: authenticates with machine credentials and posts/deletes decisions. +/// Holds a JWT refreshed on expiry or a 401. +/// +public sealed class CrowdSecAlertClient : ICrowdSecAlertClient +{ + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; + + /// + /// The crowdsec/ prefix is load-bearing: LAPI's default watcher profile matches on it and + /// answers 401 for anything else, so this cannot be a plain product string. + /// + internal const string UserAgent = "crowdsec/ModernUO-watcher-1.0"; + + private readonly HttpClient _http; + private readonly string _machineId; + private readonly string _password; + + private string _token; + private DateTime _tokenExpiresUtc = DateTime.MinValue; + + public CrowdSecAlertClient(CrowdSecSettings settings) + { + var baseUri = new Uri(settings.LapiUrl, UriKind.Absolute); // fails loud on malformed url + _http = new HttpClient { BaseAddress = baseUri, Timeout = TimeSpan.FromSeconds(30) }; + _http.DefaultRequestHeaders.Add("User-Agent", UserAgent); + _machineId = settings.MachineId; + _password = settings.Password; + } + + private async Task EnsureAuthAsync(CancellationToken token) + { + if (_token != null && DateTime.UtcNow < _tokenExpiresUtc - TimeSpan.FromMinutes(1)) + { + return; + } + + var request = new CrowdSecLoginRequest { MachineId = _machineId, Password = _password }; + using var response = await _http.PostAsJsonAsync("/v1/watchers/login", request, _jsonOptions, token) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var login = await response.Content.ReadFromJsonAsync(_jsonOptions, token) + .ConfigureAwait(false); + _token = login?.Token ?? throw new InvalidOperationException("CrowdSec login returned no token."); + + // LAPI returns an RFC3339 expiry. Parse it invariantly for the same reason we format invariantly: + // the current culture must not decide whether a machine-readable timestamp is understood. + _tokenExpiresUtc = DateTime.TryParse( + login.Expire, + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal, + out var exp + ) ? exp : DateTime.UtcNow.AddHours(1); + } + + private void Authorize(HttpRequestMessage message) => + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + + public async ValueTask PostAlertsAsync(IReadOnlyList alerts, CancellationToken token) + { + if (alerts.Count == 0) + { + return; + } + + await SendWithRetryAsync(() => + { + var message = new HttpRequestMessage(HttpMethod.Post, "/v1/alerts") + { + Content = JsonContent.Create(alerts, options: _jsonOptions) + }; + Authorize(message); + return message; + }, token).ConfigureAwait(false); + } + + public async ValueTask DeleteDecisionsAsync(string origin, IPAddress ip, CancellationToken token) + { + var query = BuildDeleteQuery(origin, ip); + await SendWithRetryAsync(() => + { + var message = new HttpRequestMessage(HttpMethod.Delete, query); + Authorize(message); + return message; + }, token).ConfigureAwait(false); + } + + /// + /// Builds the decisions-delete query string. is operator-controlled config + /// (crowdsec.json), so it must be escaped like any other untrusted value going into a URL. + /// + internal static string BuildDeleteQuery(string origin, IPAddress ip) => + $"/v1/decisions?origin={Uri.EscapeDataString(origin)}&ip={Uri.EscapeDataString(ip.ToString())}"; + + private async ValueTask SendWithRetryAsync(Func build, CancellationToken token) + { + await EnsureAuthAsync(token).ConfigureAwait(false); + + using var first = build(); + using var response = await _http.SendAsync(first, token).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.Unauthorized) + { + response.EnsureSuccessStatusCode(); + return; + } + + // Token rejected mid-flight: force a re-login and retry once. + _token = null; + await EnsureAuthAsync(token).ConfigureAwait(false); + using var retry = build(); + using var retryResponse = await _http.SendAsync(retry, token).ConfigureAwait(false); + retryResponse.EnsureSuccessStatusCode(); + } + + public void Dispose() => _http.Dispose(); +} diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs new file mode 100644 index 000000000..5855633f9 --- /dev/null +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecConfiguration.cs @@ -0,0 +1,100 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecConfiguration.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans.CrowdSec; + +/// +/// Loads the from Configuration/crowdsec.json. Loaded once; a +/// missing file writes a disabled-by-default template so operators have something to edit. +/// +public static class CrowdSecConfiguration +{ + private const string _path = "Configuration/crowdsec.json"; + + public static CrowdSecSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new CrowdSecSettings + { + LapiUrl = "http://127.0.0.1:8080", + MachineId = "", + Password = "", + Origin = "modernuo", + ManualBanDuration = TimeSpan.FromHours(168), + FlushInterval = TimeSpan.FromSeconds(1), + MaxQueue = 10000 + }; + + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for . Read once at Configure(). +/// The reporter is inert unless is true. +/// +public record CrowdSecSettings +{ + /// LAPI endpoint. Default http://127.0.0.1:8080. + [JsonPropertyName("lapiUrl")] + public string LapiUrl { get; set; } = "http://127.0.0.1:8080"; + + /// Watcher machine id from cscli machines add. Empty disables reporting. + [JsonPropertyName("machineId")] + public string MachineId { get; set; } = ""; + + /// Watcher password paired with . + [JsonPropertyName("password")] + public string Password { get; set; } = ""; + + /// Decision origin stamped on our contributions. Default modernuo. + [JsonPropertyName("origin")] + public string Origin { get; set; } = "modernuo"; + + /// Duration for manual admin bans pushed to CrowdSec. Default 168h (renewable). Finite so a missed retract self-heals. + [JsonPropertyName("manualBanDuration")] + public TimeSpan ManualBanDuration { get; set; } = TimeSpan.FromHours(168); + + /// Max time the drain coalesces before flushing a batch. Default 1s. + [JsonPropertyName("flushInterval")] + public TimeSpan FlushInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// Bounded contribution queue capacity; overflow is dropped (counted). Default 10000. + [JsonPropertyName("maxQueue")] + public int MaxQueue { get; set; } = 10000; + + [JsonIgnore] + public bool ReportingEnabled => !string.IsNullOrWhiteSpace(MachineId) && !string.IsNullOrWhiteSpace(Password); +} diff --git a/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs new file mode 100644 index 000000000..c900eb90f --- /dev/null +++ b/Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs @@ -0,0 +1,409 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: CrowdSecReporter.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Server.Logging; + +namespace Server.Network.Bans.CrowdSec; + +/// +/// Contributes locally-decided bans to CrowdSec via the LAPI alerts API. Non-blocking on the accept +/// path: enqueues onto a bounded, drop-on-overflow channel drained by a single +/// background task that coalesces by IP and POSTs batched alerts. +/// +public sealed class CrowdSecReporter : IBanReporter +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(CrowdSecReporter)); + + internal readonly record struct ReportItem(IPAddress Ip, TimeSpan Ttl, string Reason, bool Retract); + + // Bounded retry for transient LAPI failures during a drain send (network blips, 5xx). Distinct from + // CrowdSecAlertClient.SendWithRetryAsync's single 401-relogin retry, which is an auth concern. + private static readonly TimeSpan[] _retryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2)]; + + private ICrowdSecAlertClient _client; + private CrowdSecSettings _settings; + private Channel _queue; + private CancellationTokenSource _cts; + private Task _drainTask; + private int _dropped; + private int _sendFailures; + + public CrowdSecReporter() + { + } + + // Test/embedding ctor with an injected client + settings. + internal CrowdSecReporter(ICrowdSecAlertClient client, CrowdSecSettings settings) + { + _client = client; + _settings = settings; + _queue = CreateQueue(settings.MaxQueue); + } + + public string Name => "crowdsec"; + public bool CanRetract => true; + public int DroppedCount => _dropped; + + /// + /// Batches ultimately dropped after the bounded transient-retry in gave up. + /// Distinct from (queue-overflow drops on the accept path): this counts + /// sustained LAPI outages so operators can see contribution loss instead of it being silent. + /// + public int SendFailureCount => _sendFailures; + + /// The drain loop's task, so tests can assert it stays alive until the loop exits. + internal Task DrainTaskForTesting => _drainTask; + + public static void Configure() + { + BanChannel.Register(new CrowdSecReporter()); + } + + public void Register() + { + CrowdSecConfiguration.Load(); + _settings ??= CrowdSecConfiguration.Settings; + } + + public void Start(CancellationToken token) + { + if (!_settings.ReportingEnabled) + { + logger.Information("CrowdSec reporter disabled (machineId/password empty in crowdsec.json)"); + return; + } + + _client ??= new CrowdSecAlertClient(_settings); + _queue ??= CreateQueue(_settings.MaxQueue); + _cts = CancellationTokenSource.CreateLinkedTokenSource(token); + _drainTask = Task.Run(() => DrainLoop(_cts.Token), _cts.Token); + } + + public void Stop() + { + _cts?.Cancel(); + + // The flush below reads a SingleReader channel, so wait for the drain to actually exit first. + var drainExited = true; + try + { + // Wait(timeout) is false only on timeout; a throw means faulted/cancelled, which is still exited. + drainExited = _drainTask == null || _drainTask.Wait(TimeSpan.FromSeconds(2)); + } + catch + { + // Ignored: a faulted wait means the drain has completed and released the channel. + } + + _cts?.Dispose(); + _cts = null; + + if (drainExited) + { + FlushRemainingOnStop(); + } + + _client?.Dispose(); + _drainTask = null; + } + + /// + /// Best-effort bounded flush of whatever is still queued at shutdown. Blocking is correct here — the + /// loop has stopped ticking — but must not happen on the loop thread: runs where + /// SynchronizationContext.Current is the EventLoopContext, and a captured continuation + /// would be posted to a queue nothing pumps any more. keeps the + /// chain on the pool; the bounded wait caps a wedged send at a few seconds of shutdown. + /// + private void FlushRemainingOnStop() + { + if (_queue == null || _client == null) + { + return; + } + + _queue.Writer.TryComplete(); + + List reports = []; + List retracts = []; + while (_queue.Reader.TryRead(out var item)) + { + (item.Retract ? retracts : reports).Add(item); + } + + if (reports.Count == 0 && retracts.Count == 0) + { + return; + } + + try + { + if (!Task.Run(() => FlushRemainingOnStopAsync(reports, retracts)).Wait(TimeSpan.FromSeconds(4))) + { + logger.Warning( + "CrowdSec flush-on-stop timed out; {Count} item(s) not contributed", + reports.Count + retracts.Count + ); + } + } + catch (Exception e) + { + logger.Warning(e, "CrowdSec flush-on-stop failed"); + } + } + + /// + /// Uses a fresh token, not the drain loop's already-cancelled one, which would fail every send + /// immediately. Reports go as one deduped batch; retracts go as individual DELETEs so an admin's + /// unban propagates on a clean shutdown. Leftovers self-heal via + /// . + /// + private async Task FlushRemainingOnStopAsync(List reports, List retracts) + { + using var flushCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + + if (reports.Count > 0) + { + var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow); + try + { + await _client.PostAlertsAsync(alerts, flushCts.Token).ConfigureAwait(false); + } + catch (Exception e) + { + logger.Warning(e, "CrowdSec flush-on-stop reports failed"); + RecordSendFailure(alerts.Count); + } + } + + HashSet seen = []; + for (var i = 0; i < retracts.Count; i++) + { + if (flushCts.IsCancellationRequested) + { + break; // out of budget; the rest self-heal via ManualBanDuration + } + + var ip = retracts[i].Ip; + if (!seen.Add(ip.ToString())) + { + continue; + } + + try + { + await _client.DeleteDecisionsAsync(_settings.Origin, ip, flushCts.Token).ConfigureAwait(false); + } + catch (Exception e) + { + logger.Warning(e, "CrowdSec flush-on-stop retract failed for {Address}", ip); + RecordSendFailure(1); + } + } + } + + public void Report(IPAddress address, TimeSpan ttl, string reason) => + Enqueue(new ReportItem(address, ttl, reason, false)); + + public void Retract(IPAddress address) => + Enqueue(new ReportItem(address, TimeSpan.Zero, "retract", true)); + + private void Enqueue(ReportItem item) + { + if (_queue == null || !_queue.Writer.TryWrite(item)) + { + Interlocked.Increment(ref _dropped); + } + } + + // FullMode.Wait (the default) makes TryWrite return false immediately when the channel is full + // instead of blocking the caller — exactly the non-blocking drop-on-overflow behavior the accept + // path requires. DropWrite would silently discard the new item and always report success, which + // would make overflow undetectable. + private static Channel CreateQueue(int capacity) => + Channel.CreateBounded(new BoundedChannelOptions(Math.Max(1, capacity)) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true + }); + + // Must return Task: Start() passes this to Task.Run, which has no Func overload, so a + // ValueTask would bind to Task.Run and yield a Task that completes at the first + // await rather than when the loop exits. + private async Task DrainLoop(CancellationToken token) + { + var reader = _queue.Reader; + + while (!token.IsCancellationRequested) + { + try + { + if (!await reader.WaitToReadAsync(token).ConfigureAwait(false)) + { + return; + } + + // Coalesce a burst before flushing. + await Task.Delay(_settings.FlushInterval, token).ConfigureAwait(false); + + List reports = []; + List retracts = []; + while (reader.TryRead(out var item)) + { + (item.Retract ? retracts : reports).Add(item); + } + + if (reports.Count > 0) + { + var alerts = BuildAlerts(reports, _settings, DateTime.UtcNow); + if (!await SendWithBoundedRetryAsync(() => _client.PostAlertsAsync(alerts, token), token) + .ConfigureAwait(false)) + { + RecordSendFailure(alerts.Count); + } + } + + for (var i = 0; i < retracts.Count; i++) + { + var ip = retracts[i].Ip; + if (!await SendWithBoundedRetryAsync(() => _client.DeleteDecisionsAsync(_settings.Origin, ip, token), token) + .ConfigureAwait(false)) + { + RecordSendFailure(1); + } + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) + { + // Contribution is auxiliary: log and keep draining. Never crash the shard. + logger.Warning(e, "CrowdSec contribution flush failed; dropped this batch"); + } + } + } + + /// + /// Up to 3 attempts (1 initial + 2 retries) backing off 1s then 2s, for transient LAPI failures + /// (network blips, 5xx). Backoff uses so it never blocks the thread, and a + /// cancellation during it propagates so the drain loop exits cleanly. Returns false rather than + /// throwing once attempts are exhausted, so the caller counts the drop and keeps draining. + /// + private static async ValueTask SendWithBoundedRetryAsync(Func send, CancellationToken token) + { + for (var attempt = 0; ; attempt++) + { + try + { + await send().ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + if (attempt >= _retryDelays.Length) + { + logger.Warning(e, "CrowdSec send failed after {Attempts} attempt(s); giving up", attempt + 1); + return false; + } + + var delay = _retryDelays[attempt]; + logger.Warning(e, "CrowdSec send failed (attempt {Attempt}); retrying in {Delay}", attempt + 1, delay); + await Task.Delay(delay, token).ConfigureAwait(false); + } + } + } + + private void RecordSendFailure(int itemCount) + { + var total = Interlocked.Increment(ref _sendFailures); + logger.Warning( + "CrowdSec contribution batch dropped after retries ({Items} item(s)); total dropped batches: {Total}", + itemCount, + total + ); + } + + /// Coalesces items by IP (last write wins) and builds one alert per unique address. + internal static List BuildAlerts(IEnumerable items, CrowdSecSettings settings, DateTime nowUtc) + { + Dictionary byIp = []; + foreach (var item in items) + { + byIp[item.Ip.ToString()] = item; + } + + var timestamp = FormatTimestamp(nowUtc); + var alerts = new List(byIp.Count); + + foreach (var (value, item) in byIp) + { + var ttl = item.Reason == BanReasons.Manual || item.Ttl <= TimeSpan.Zero ? settings.ManualBanDuration : item.Ttl; + var scenario = $"{settings.Origin}/{item.Reason}"; + + alerts.Add(new CrowdSecAlert + { + Scenario = scenario, + Message = $"ModernUO {item.Reason} ban for {value}", + StartAt = timestamp, + StopAt = timestamp, + Source = new CrowdSecSource { Scope = "Ip", Value = value }, + Decisions = + [ + new CrowdSecDecisionDto + { + Origin = settings.Origin, + Type = "ban", + Scope = "Ip", + Value = value, + Duration = FormatDuration(ttl), + Scenario = scenario + } + ] + }); + } + + return alerts; + } + + /// + /// ISO8601/RFC3339 UTC timestamp for start_at/stop_at. LAPI parses these with Go's + /// time.RFC3339 and answers 500 when the parse fails, so the format must be culture-independent: + /// ':' is the *time separator* specifier in a custom .NET format string, and a shard running under a + /// culture like fi-FI would otherwise emit "T12.34.56.789Z". InvariantCulture also pins the Gregorian + /// calendar, which non-Gregorian cultures (th-TH, ar-SA) would otherwise shift the year for. + /// + internal static string FormatTimestamp(DateTime time) => + (time.Kind == DateTimeKind.Utc ? time : time.ToUniversalTime()) + .ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture); + + /// CrowdSec accepts Go durations; whole seconds are unambiguous and sufficient. + internal static string FormatDuration(TimeSpan ttl) + { + var seconds = (long)ttl.TotalSeconds; + return $"{Math.Max(1, seconds)}s"; + } +} diff --git a/Projects/Server/Network/Firewall/BaseFirewallEntry.cs b/Projects/UOContent/Network/Firewall/BaseFirewallEntry.cs similarity index 100% rename from Projects/Server/Network/Firewall/BaseFirewallEntry.cs rename to Projects/UOContent/Network/Firewall/BaseFirewallEntry.cs diff --git a/Projects/Server/Network/Firewall/CidrFirewallEntry.cs b/Projects/UOContent/Network/Firewall/CidrFirewallEntry.cs similarity index 70% rename from Projects/Server/Network/Firewall/CidrFirewallEntry.cs rename to Projects/UOContent/Network/Firewall/CidrFirewallEntry.cs index db74cf620..06527ab71 100644 --- a/Projects/Server/Network/Firewall/CidrFirewallEntry.cs +++ b/Projects/UOContent/Network/Firewall/CidrFirewallEntry.cs @@ -25,8 +25,15 @@ public class CidrFirewallEntry : BaseFirewallEntry public override UInt128 MaxIpAddress { get; } public CidrFirewallEntry(string ipAddressOrCidr) - : this(ParseIPAddress(ipAddressOrCidr, out var prefixLength), prefixLength) { + // Core owns the CIDR -> normalized range parse. + if (!IPAddressUtility.TryParseCidrRange(ipAddressOrCidr, out var min, out var max)) + { + throw new ArgumentException("Invalid IP address or CIDR.", nameof(ipAddressOrCidr)); + } + + MinIpAddress = min; + MaxIpAddress = max; } public CidrFirewallEntry(IPAddress minAddress, IPAddress maxAddress) @@ -50,26 +57,4 @@ public class CidrFirewallEntry : BaseFirewallEntry MaxIpAddress = Utility.CreateCidrAddress(bytes, prefixLength, true); } - private static IPAddress ParseIPAddress(ReadOnlySpan ipString, out int prefixLength) - { - var slashIndex = ipString.IndexOf('/'); - var ipAddress = IPAddress.Parse(slashIndex > -1 ? ipString[..slashIndex] : ipString); - var maxPrefixLength = ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32; - - if (slashIndex == -1) - { - prefixLength = maxPrefixLength; - } - else - { - var prefixPart = ipString[(slashIndex + 1)..]; - - if (!int.TryParse(prefixPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength) - { - throw new ArgumentException("Invalid prefix length."); - } - } - - return ipAddress; - } } diff --git a/Projects/UOContent/Network/Firewall/Firewall.cs b/Projects/UOContent/Network/Firewall/Firewall.cs new file mode 100644 index 000000000..36c11bbf9 --- /dev/null +++ b/Projects/UOContent/Network/Firewall/Firewall.cs @@ -0,0 +1,413 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: Firewall.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Runtime.CompilerServices; +using Server.Collections; +using Server.Json; +using Server.Logging; + +namespace Server.Network; + +public static class Firewall +{ + // Single-threaded: the accept path, admin gump/command, TTL expiry timer, and boot load all run on + // the main game loop, so no locks, caches, or version counters are needed. _entries is the + // authoritative store; _index is a derived, rebuild-on-demand SortedRangeIndex used only for the + // accept-path IsBlocked lookup, over the same primitive the blocklist uses (see BlocklistSnapshot). + // See dev-docs/ip-bans-and-allowlists.md. + private static readonly List _entries = []; + + // Entries with a TTL: entry -> absolute expiry tick (Core.TickCount). Permanent entries are absent. + private static readonly Dictionary _expiring = []; + + private static SortedRangeIndex _index = SortedRangeIndex.Empty; + private static bool _indexDirty; + + private static readonly ILogger logger = LogFactory.GetLogger(typeof(Firewall)); + private const string _path = "Configuration/firewall.json"; + private const string _legacyPath = "firewall.cfg"; + private static bool _dirty; + private static bool _configured; + + public static int FirewallSetCount => _entries.Count; + + public static void ReadFirewallSet(Action> callback) => callback(_entries); + + public static bool IsBlocked(IPAddress address) + { + if (_entries.Count == 0) + { + return false; + } + + EnsureIndex(); + return _index.Contains(address.ToUInt128()); + } + + // Rebuilds the derived lookup index from the authoritative _entries list, but only when entries have + // changed since the last build. Runs on the main game loop, so the pooled build buffer is single-threaded + // (mt: false); only the two final SortedRangeIndex arrays are heap-allocated. + private static void EnsureIndex() + { + if (!_indexDirty) + { + return; + } + + using var ranges = PooledRefList.Range>.Create(_entries.Count, mt: false); + for (var i = 0; i < _entries.Count; i++) + { + var entry = _entries[i]; + ranges.Add(new SortedRangeIndex.Range(entry.MinIpAddress, entry.MaxIpAddress)); + } + + ranges.Sort(SortedRangeIndex.ByMin); + _index = SortedRangeIndex.Build(ranges.AsSpan()); + _indexDirty = false; + } + + public static bool Add(IFirewallEntry firewallEntry) => Add(firewallEntry, TimeSpan.Zero); + + /// + /// Adds an entry. <= means permanent. Returns false + /// if the entry was already present. + /// + // Indexed scan (no closure allocation); firewall lists are small, so O(n) is negligible and this + // stays off the hot path (Add/Remove are admin/boot actions, not the accept path). + private static int IndexOfEntry(IFirewallEntry entry) + { + for (var i = 0; i < _entries.Count; i++) + { + if (_entries[i].CompareTo(entry) == 0) + { + return i; + } + } + + return -1; + } + + public static bool Add(IFirewallEntry firewallEntry, TimeSpan ttl, bool persist = true) + { + if (firewallEntry == null || IndexOfEntry(firewallEntry) >= 0) + { + return false; + } + + _entries.Add(firewallEntry); + + if (ttl > TimeSpan.Zero) + { + _expiring[firewallEntry] = Core.TickCount + (long)ttl.TotalMilliseconds; + } + + _indexDirty = true; + + if (persist) + { + MarkDirty(); + } + + return true; + } + + public static bool Remove(IFirewallEntry entry) + { + if (entry == null) + { + return false; + } + + var index = IndexOfEntry(entry); + if (index < 0) + { + return false; + } + + // Remove the stored instance from _expiring (not the passed reference), so a value-equal + // entry created elsewhere still clears the TTL bookkeeping. + var stored = _entries[index]; + _entries.RemoveAt(index); + _expiring.Remove(stored); + _indexDirty = true; + MarkDirty(); + return true; + } + + /// + /// Removes every entry whose TTL has elapsed. Called from the main-thread maintenance timer. + /// + internal static void ExpireEntries(long nowTicks) + { + if (_expiring.Count == 0) + { + return; + } + + List expired = null; + foreach (var (entry, expiresAt) in _expiring) + { + if (expiresAt - nowTicks <= 0) + { + (expired ??= []).Add(entry); + } + } + + if (expired == null) + { + return; + } + + for (var i = 0; i < expired.Count; i++) + { + var entry = expired[i]; + _entries.Remove(entry); + _expiring.Remove(entry); + } + + _indexDirty = true; + MarkDirty(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IFirewallEntry ToFirewallEntry(object entry) => + entry switch + { + IFirewallEntry firewallEntry => firewallEntry, + IPAddress address => new SingleIpFirewallEntry(address), + string s => ToFirewallEntry(s), + _ => null + }; + + public static IFirewallEntry ToFirewallEntry(string entry) + { + if (entry == null) + { + return null; + } + + try + { + var rangeSeparator = entry.IndexOf('-'); + if (rangeSeparator > -1) + { + return new CidrFirewallEntry( + IPAddress.Parse(entry.AsSpan(0, rangeSeparator)), + IPAddress.Parse(entry.AsSpan(rangeSeparator + 1)) + ); + } + + if (entry.IndexOf('/') > -1) + { + return new CidrFirewallEntry(entry); + } + + return new SingleIpFirewallEntry(entry); + } + catch + { + return null; + } + } + + public static void Configure() + { + if (_configured) + { + return; + } + _configured = true; + + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + LoadFrom(JsonConfig.Deserialize(path)); + } + else + { + var legacyPath = ResolveLegacyCfgPath(); + if (legacyPath != null) + { + MigrateLegacyCfg(legacyPath); + Save(); // materialize firewall.json; the .cfg is no longer read after this + TryMarkLegacyCfgMigrated(legacyPath); + } + } + + // Main-thread maintenance: expire TTLs and flush pending writes. No background thread. + Timer.DelayCall(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30), Maintenance); + + // Expose the set to the accept path. Everything else (gump, commands, persistence) keeps using + // the Firewall API directly; only the per-connection question goes through the filter registry. + ConnectionFilters.Register(FirewallConnectionFilter.Instance); + } + + private static void Maintenance() + { + ExpireEntries(Core.TickCount); + + if (_dirty) + { + Save(); + } + } + + private static void MarkDirty() => _dirty = true; + + internal static void LoadFrom(FirewallSettings settings) + { + if (settings?.Entries == null) + { + return; + } + + // Core.Now: this runs on the game loop, via the Configure sweep. + var now = Core.Now; + var records = settings.Entries; + for (var i = 0; i < records.Length; i++) + { + var record = records[i]; + var entry = ToFirewallEntry(record.Value); + if (entry == null) + { + logger.Warning("Ignoring unparseable firewall entry \"{Entry}\"", record.Value); + continue; + } + + var ttl = TimeSpan.Zero; + if (record.Expires is { } expires) + { + ttl = expires - now; + if (ttl <= TimeSpan.Zero) + { + continue; // already expired + } + } + + Add(entry, ttl, persist: false); + } + } + + internal static FirewallSettings ToSettings() + { + // expires is derived below as now + (expiresAtTick - nowTicks), so both operands must come from + // the same instant. Core.Now and Core.TickCount are refreshed together each loop iteration; a + // fresh DateTime.UtcNow here would bake the loop's lag into every persisted expiry. + var now = Core.Now; + var nowTicks = Core.TickCount; + var list = new List(_entries.Count); + + for (var i = 0; i < _entries.Count; i++) + { + var entry = _entries[i]; + DateTime? expires = null; + if (_expiring.TryGetValue(entry, out var expiresAtTick)) + { + expires = now.AddMilliseconds(expiresAtTick - nowTicks); + } + + list.Add(new FirewallEntryRecord { Value = entry.ToString(), Expires = expires }); + } + + return new FirewallSettings { Entries = list.ToArray() }; + } + + public static void Save() + { + _dirty = false; + var path = Path.Join(Core.BaseDirectory, _path); + var tmp = $"{path}.tmp"; + JsonConfig.Serialize(tmp, ToSettings()); + File.Move(tmp, path, overwrite: true); // atomic swap + } + + /// + /// Locates the legacy firewall.cfg to migrate. The modern convention is , + /// checked first; the pre-collapse AdminFirewall used a bare relative path (resolved against the + /// process's current working directory), which may differ from when the + /// shard is launched from elsewhere, so that's checked as a fallback. Returns null if neither exists. + /// + private static string ResolveLegacyCfgPath() + { + var underBaseDirectory = Path.Join(Core.BaseDirectory, _legacyPath); + if (File.Exists(underBaseDirectory)) + { + return underBaseDirectory; + } + + return File.Exists(_legacyPath) ? _legacyPath : null; + } + + private static void MigrateLegacyCfg(string legacyPath) + { + var searchValues = SearchValues.Create("*Xx?"); + + using var reader = new StreamReader(legacyPath); + while (reader.ReadLine() is { } line) + { + line = line.Trim(); + if (line.Length == 0) + { + continue; + } + + if (line.AsSpan().ContainsAny(searchValues)) + { + logger.Warning("Legacy firewall entry \"{Entry}\" ignored during migration", line); + continue; + } + + var entry = ToFirewallEntry(line); + if (entry != null) + { + Add(entry, TimeSpan.Zero, persist: false); + } + } + + logger.Information("Migrated {Count} entr(ies) from legacy firewall.cfg to firewall.json", _entries.Count); + } + + /// + /// Renames the migrated .cfg to firewall.cfg.migrated so it isn't re-scanned on the next + /// boot and operators can see it was already migrated. Best-effort: a locked/read-only file must not + /// fail startup, since the migration itself (firewall.json) already succeeded. + /// + private static void TryMarkLegacyCfgMigrated(string legacyPath) + { + try + { + File.Move(legacyPath, $"{legacyPath}.migrated", overwrite: true); + } + catch (Exception e) + { + logger.Warning(e, "Could not rename migrated legacy firewall file \"{Path}\"", legacyPath); + } + } + + internal static void ResetForTesting() + { + _entries.Clear(); + _expiring.Clear(); + _index = SortedRangeIndex.Empty; + _indexDirty = false; + _configured = false; + } +} diff --git a/Projects/UOContent/Network/Firewall/FirewallConnectionFilter.cs b/Projects/UOContent/Network/Firewall/FirewallConnectionFilter.cs new file mode 100644 index 000000000..570e33f19 --- /dev/null +++ b/Projects/UOContent/Network/Firewall/FirewallConnectionFilter.cs @@ -0,0 +1,52 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallConnectionFilter.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Net; +using System.Threading; + +namespace Server.Network; + +/// +/// Exposes the admin-curated set to the accept path as an +/// . The firewall keeps its own API (the admin gump and commands mutate +/// it directly); this is only the accept-path adapter, since a static class cannot implement an +/// interface. It is the cheapest gate to consult — an empty set costs one length compare — so +/// registers it first. +/// +internal sealed class FirewallConnectionFilter : IConnectionFilter +{ + public static readonly FirewallConnectionFilter Instance = new(); + + private FirewallConnectionFilter() + { + } + + public string Name => "firewall"; + + // Firewall.Configure() owns loading and registration. + public void Register() + { + } + + // Nothing to hydrate: the set loads at Configure and is maintained by a main-loop timer. + public void Start(CancellationToken token) + { + } + + /// Flushes pending writes on the way down so a TTL expiry or late admin edit is not lost. + public void Stop() => Firewall.Save(); + + public bool ShouldDeny(IPAddress address) => Firewall.IsBlocked(address); +} diff --git a/Projects/UOContent/Network/Firewall/FirewallSettings.cs b/Projects/UOContent/Network/Firewall/FirewallSettings.cs new file mode 100644 index 000000000..dd0281c92 --- /dev/null +++ b/Projects/UOContent/Network/Firewall/FirewallSettings.cs @@ -0,0 +1,42 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: FirewallSettings.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Text.Json.Serialization; + +namespace Server.Network; + +/// +/// Persisted local firewall entries (manual admin bans). Stored at Configuration/firewall.json. +/// Auto-detected rate-limit trips are never persisted — they are contributed to CrowdSec, not stored here. +/// +public record FirewallSettings +{ + [JsonPropertyName("entries")] + public FirewallEntryRecord[] Entries { get; set; } = []; +} + +/// +/// One persisted entry. is a single IP, a min-max range, or CIDR. +/// is UTC wall-clock; null means permanent. +/// +public record FirewallEntryRecord +{ + [JsonPropertyName("value")] + public string Value { get; set; } + + [JsonPropertyName("expires")] + public DateTime? Expires { get; set; } +} diff --git a/Projects/Server/Network/Firewall/IFirewallEntry.cs b/Projects/UOContent/Network/Firewall/IFirewallEntry.cs similarity index 100% rename from Projects/Server/Network/Firewall/IFirewallEntry.cs rename to Projects/UOContent/Network/Firewall/IFirewallEntry.cs diff --git a/Projects/Server/Network/Firewall/SingleIpFirewallEntry.cs b/Projects/UOContent/Network/Firewall/SingleIpFirewallEntry.cs similarity index 100% rename from Projects/Server/Network/Firewall/SingleIpFirewallEntry.cs rename to Projects/UOContent/Network/Firewall/SingleIpFirewallEntry.cs diff --git a/Projects/UOContent/Network/GameServer.cs b/Projects/UOContent/Network/GameServer.cs index 9fc170d69..b9de09515 100644 --- a/Projects/UOContent/Network/GameServer.cs +++ b/Projects/UOContent/Network/GameServer.cs @@ -6,13 +6,21 @@ public static partial class GameServer { public class GameLoginEventArgs { - public GameLoginEventArgs(NetState state, string un, string pw) + public GameLoginEventArgs(NetState state, string un, string pw, bool preAuthenticated) { State = state; Username = un; Password = pw; + PreAuthenticated = preAuthenticated; } + /// + /// The auth id presented on this game login was issued to this account, from this address, + /// after the account login packet verified the password. Read-only so a subscriber cannot + /// grant itself the skip. + /// + public bool PreAuthenticated { get; } + public NetState State { get; } public string Username { get; } diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs new file mode 100644 index 000000000..23075f7ff --- /dev/null +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs @@ -0,0 +1,440 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: LoginAllowlist.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Server.Logging; +using Server.Network.Bans; + +namespace Server.Network; + +/// +/// An allowlist addresses earn by logging in successfully, so a reputation feed cannot get a known player +/// blocked and a flaky connection cannot get one globally banned. +/// +/// +/// Consulted only after the blocklist has already matched, so a normal accept pays nothing for it. An entry +/// is evidence rather than a licence: enough strikes inside the window revokes it. It cannot bootstrap, so +/// it does not replace . Both dictionaries are game-loop state; only the file +/// write runs off-loop, over a snapshot taken on the loop. +/// See dev-docs/ip-bans-and-allowlists.md. +/// +public static class LoginAllowlist +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(LoginAllowlist)); + + // Address (normalized v6 bits) -> unix seconds of its last successful login. Loop-only. + private static readonly Dictionary _allowed = []; + + // Suppressed contributions in the current window. Only holds allowlisted addresses, so it is bounded by + // _allowed and cannot be grown by an attacker. + private static readonly Dictionary _strikes = []; + + // Reused: past ~5,300 entries a fresh UInt128[] is an LOH allocation, once per flush. Grown + // geometrically, never shrunk. + private static UInt128[] _addressBuffer = []; + private static long[] _stampBuffer = []; + + private static bool _enabled; + private static string _path; + private static long _ttlSeconds; + private static int _escalateAfterStrikes; + private static long _strikeWindowSeconds; + private static bool _dirty; + + // Loop-only. The writer owns the buffers until it posts completion back, so a flush landing mid-write + // waits rather than overwriting them. + private static bool _writing; + + public static int Count => _allowed.Count; + + private struct Strike + { + public int Count; + public long WindowStart; // unix seconds; 0 means "no window open" + } + + public static void Configure() + { + LoginAllowlistConfiguration.Load(); + var s = LoginAllowlistConfiguration.Settings; + + _enabled = s.Enabled && !string.IsNullOrWhiteSpace(s.File) && s.Ttl > TimeSpan.Zero; + if (!_enabled) + { + return; + } + + _path = Path.IsPathRooted(s.File) ? s.File : Path.Join(Core.BaseDirectory, s.File); + _ttlSeconds = (long)s.Ttl.TotalSeconds; + _escalateAfterStrikes = s.EscalateAfterStrikes; + _strikeWindowSeconds = (long)s.StrikeWindow.TotalSeconds; + } + + public static void Initialize() + { + if (!_enabled) + { + logger.Information("Login allowlist disabled"); + return; + } + + Load(); + + var interval = LoginAllowlistConfiguration.Settings.FlushInterval; + if (interval <= TimeSpan.Zero) + { + interval = TimeSpan.FromHours(1); + } + + Timer.DelayCall(interval, interval, Flush); + + // HandleClosed skips InvokeShutdown when the server crashed, so the crash path needs its own. + EventSink.Shutdown += OnShutdown; + EventSink.ServerCrashed += OnCrashed; + } + + /// + /// Records a successful authentication. Private addresses are skipped: a LAN or loopback login says + /// nothing about the public internet. + /// + public static void RecordLogin(IPAddress address) => RecordLogin(address, ToUnixSeconds(Core.Now)); + + /// The pure write, split out so policy can be tested without a clock. + internal static void RecordLogin(IPAddress address, long nowUnix) + { + if (!_enabled || address == null || address.IsPrivateNetwork()) + { + return; + } + + var key = address.ToUInt128(); + _allowed[key] = nowUnix; + + // A fresh login clears the tally: someone just proved they hold an account. + _strikes.Remove(key); + _dirty = true; + } + + /// + /// True when this address logged in within the TTL. Expiry is decided on read, so a stale entry left for + /// the next flush can never allow anything. + /// + public static bool IsAllowed(IPAddress address) => IsAllowed(address, ToUnixSeconds(Core.Now)); + + /// The pure decision, split out so the TTL policy can be tested without a clock. + internal static bool IsAllowed(IPAddress address, long nowUnix) + { + if (!_enabled || address == null) + { + return false; + } + + return _allowed.TryGetValue(address.ToUInt128(), out var stamp) && nowUnix - stamp <= _ttlSeconds; + } + + public static bool IsExemptFromEscalation(IPAddress address, string reason) => + IsExemptFromEscalation(address, reason, ToUnixSeconds(Core.Now)); + + /// + /// Whether this contribution should be dropped instead of escalated, counting a strike if so. Not a pure + /// read — calling it is what spends the address's allowance. + /// + internal static bool IsExemptFromEscalation(IPAddress address, string reason, long nowUnix) + { + // An operator's explicit ban, or a reason nobody opted in, escalates untouched. + if (!BanReasons.IsBehavioral(reason) || !IsAllowed(address, nowUnix)) + { + return false; + } + + if (_escalateAfterStrikes <= 0) + { + return true; // revocation disabled: an entry is unconditional + } + + var key = address.ToUInt128(); + _strikes.TryGetValue(key, out var strike); + + if (strike.WindowStart == 0 || nowUnix - strike.WindowStart > _strikeWindowSeconds) + { + strike = new Strike { WindowStart = nowUnix }; + } + + strike.Count++; + + if (strike.Count < _escalateAfterStrikes) + { + _strikes[key] = strike; + return true; + } + + // Allowance spent: drop the entry so this and all after it escalate. Earned back by logging in. + _allowed.Remove(key); + _strikes.Remove(key); + _dirty = true; + + logger.Information( + "{Address} revoked from the login allowlist after {Count} suppressed contribution(s); last was '{Reason}'", + address, + strike.Count, + reason + ); + + return false; + } + + internal static void LoadForTesting(bool enabled, long ttlSeconds, int escalateAfterStrikes = 0, long strikeWindowSeconds = 3600) + { + _allowed.Clear(); + _strikes.Clear(); + _writing = false; + _dirty = false; + _enabled = enabled; + _ttlSeconds = ttlSeconds; + _escalateAfterStrikes = escalateAfterStrikes; + _strikeWindowSeconds = strikeWindowSeconds; + _path = null; + } + + private static long ToUnixSeconds(DateTime utc) => (long)(utc - DateTime.UnixEpoch).TotalSeconds; + + private static void Flush() + { + // A save owns the disk and nothing here is urgent. _dirty stays set, so skipping loses nothing. + // See the threading policy in CLAUDE.md (rules #3 and #10). + if (!_enabled || !_dirty || _writing || World.Saving || World.WorldState == WorldState.PendingSave) + { + return; + } + + var count = Snapshot(out var dropped); + var addresses = _addressBuffer; + var stamps = _stampBuffer; + var path = _path; + + _dirty = false; + _writing = true; + + _ = Task.Run( + () => + { + var written = Write(path, addresses, stamps, count, dropped); + + // _writing and _dirty are loop state, so the writer hands the release back. Rule #10. + Core.LoopContext.Post( + () => + { + _writing = false; + if (!written) + { + _dirty = true; // nothing reached disk; the next flush retries + } + } + ); + } + ); + } + + /// + /// A crash is the case the flush interval cannot cover, so write on the way down. Runs on whichever + /// thread faulted, and the dictionaries are loop state, so it only writes when that is the loop. + /// + private static void OnCrashed(ServerCrashedEventArgs e) + { + if (Thread.CurrentThread == Core.Thread) + { + OnShutdown(); + } + } + + /// Synchronous: nothing schedules after this, so a handed-off write would reach no disk. + private static void OnShutdown() + { + // A write already in flight holds the buffers and has all but the last moments of the list. + if (!_enabled || !_dirty || _writing) + { + return; + } + + var count = Snapshot(out var dropped); + _dirty = false; + + Write(_path, _addressBuffer, _stampBuffer, count, dropped); + } + + /// + /// Prunes expired entries and copies what survives into the shared buffers. Returns the live count; the + /// buffers run longer and everything past it is stale. + /// + private static int Snapshot(out int dropped) + { + var nowUnix = ToUnixSeconds(Core.Now); + var cutoff = nowUnix - _ttlSeconds; + + if (_addressBuffer.Length < _allowed.Count) + { + // Geometric so a shard adding addresses one at a time does not reallocate every flush. + var size = Math.Max(_allowed.Count, Math.Max(64, _addressBuffer.Length * 2)); + _addressBuffer = new UInt128[size]; + _stampBuffer = new long[size]; + } + + var count = 0; + dropped = 0; + + foreach (var (address, stamp) in _allowed) + { + if (stamp < cutoff) + { + _allowed.Remove(address); + _strikes.Remove(address); + dropped++; + continue; + } + + _addressBuffer[count] = address; + _stampBuffer[count] = stamp; + count++; + } + + PruneStaleStrikes(nowUnix); + return count; + } + + /// Drops tallies whose window has closed. + private static void PruneStaleStrikes(long nowUnix) + { + if (_strikes.Count == 0) + { + return; + } + + foreach (var (address, strike) in _strikes) + { + if (nowUnix - strike.WindowStart > _strikeWindowSeconds) + { + _strikes.Remove(address); + } + } + } + + /// Writes the list out. Returns false when nothing reached disk, so the caller can retry. + private static bool Write(string path, UInt128[] addresses, long[] stamps, int count, int dropped) + { + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + // Sibling + swap, so a reader never sees a half-written list. + var tmp = path + ".tmp"; + + using (var writer = new StreamWriter(tmp, false, new UTF8Encoding(false), 1 << 16)) + { + writer.Write("# modernuo-login-allowlist generated="); + writer.Write(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); + writer.Write(" count="); + writer.Write(count); + writer.Write('\n'); + + for (var i = 0; i < count; i++) + { + writer.Write(addresses[i].ToIpAddress().ToString()); + writer.Write(' '); + writer.Write(stamps[i]); + writer.Write('\n'); + } + } + + File.Move(tmp, path, true); + + if (dropped > 0) + { + logger.Information("Login allowlist wrote {Count} entr(ies), dropped {Dropped} past TTL", count, dropped); + } + + return true; + } + catch (Exception e) + { + // Recoverable: entries are still in memory and the next flush retries. + logger.Warning(e, "Could not write the login allowlist to \"{Path}\"", path); + return false; + } + } + + private static void Load() + { + if (!File.Exists(_path)) + { + logger.Information("Login allowlist empty: no file at \"{Path}\"", _path); + return; + } + + var cutoff = ToUnixSeconds(Core.Now) - _ttlSeconds; + var loaded = 0; + var skipped = 0; + + try + { + foreach (var line in File.ReadLines(_path)) + { + var span = line.AsSpan().Trim(); + if (span.Length == 0 || span[0] == '#' || span[0] == ';') + { + continue; + } + + var sep = span.IndexOf(' '); + if (sep <= 0 || + !IPAddress.TryParse(span[..sep], out var address) || + !long.TryParse(span[(sep + 1)..].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var stamp)) + { + skipped++; + continue; + } + + // Expired on disk: do not carry a stranger into memory. + if (stamp < cutoff) + { + skipped++; + _dirty = true; // the file is now out of date; the next flush rewrites it + continue; + } + + _allowed[address.ToUInt128()] = stamp; + loaded++; + } + } + catch (Exception e) + { + // Fail open: an unreadable list allows nobody, which beats refusing to boot. + logger.Warning(e, "Could not read the login allowlist at \"{Path}\"; continuing with {Count}", _path, _allowed.Count); + return; + } + + logger.Information("Login allowlist loaded {Loaded} entr(ies) ({Skipped} expired or malformed)", loaded, skipped); + } +} diff --git a/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs new file mode 100644 index 000000000..5c673eb10 --- /dev/null +++ b/Projects/UOContent/Network/LoginAllowlist/LoginAllowlistConfiguration.cs @@ -0,0 +1,106 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: LoginAllowlistConfiguration.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network; + +/// +/// Loads the from Configuration/login-allowlist.json. Loaded +/// once; a missing file writes a template so operators have something to edit. +/// +public static class LoginAllowlistConfiguration +{ + private const string _path = "Configuration/login-allowlist.json"; + + public static LoginAllowlistSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new LoginAllowlistSettings(); + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for : which addresses have recently proven they carry a +/// real player, how long that proof counts, and how much misbehaviour revokes it. +/// +/// +/// The recency window is the point. Consumer addresses are reassigned constantly, and on CGNAT the same +/// address fronts a different subscriber week to week, so a list without a TTL becomes a list of strangers. +/// +public record LoginAllowlistSettings +{ + /// Whether successful logins are recorded and consulted at all. Disabled makes the list inert. + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + /// + /// Where the list is persisted. A relative path resolves against . + /// Plain text, one address unix-seconds pair per line, so it can be read and edited by hand. + /// Set to "" to disable. + /// + [JsonPropertyName("file")] + public string File { get; set; } = "Configuration/login-allowlist.txt"; + + /// + /// How long a successful login allowlists its address; dropped on the next flush after that. 90 days + /// covers a player who takes a season off without carrying a reassigned address indefinitely. + /// + [JsonPropertyName("ttl")] + public TimeSpan Ttl { get; set; } = TimeSpan.FromDays(90); + + /// + /// How often a changed list is written out. A clean shutdown always writes, so this only bounds what a + /// crash loses — and an entry is re-earned by the next login. Hourly against a 90-day TTL, because each + /// flush walks the whole list on the game loop. + /// + [JsonPropertyName("flushInterval")] + public TimeSpan FlushInterval { get; set; } = TimeSpan.FromHours(1); + + /// + /// How many suppressed contributions inside revoke an address's entry. Past + /// this it escalates like anything else until it earns a new entry by logging in again. + /// + /// + /// Generous on purpose: local defenses never stop applying, so a high threshold only delays the external + /// ban. A bad line might trip a gate a few times an hour; a host being used to flood burns through this + /// in seconds. Set to 0 to never revoke. + /// + [JsonPropertyName("escalateAfterStrikes")] + public int EscalateAfterStrikes { get; set; } = 10; + + /// Rolling window the strike count is measured over. A quiet hour clears the tally. + [JsonPropertyName("strikeWindow")] + public TimeSpan StrikeWindow { get; set; } = TimeSpan.FromHours(1); +} diff --git a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs new file mode 100644 index 000000000..20e351457 --- /dev/null +++ b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlist.cs @@ -0,0 +1,340 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ManualAllowlist.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Server.Logging; + +namespace Server.Network.Bans; + +/// +/// The operator's own "leave this address alone" list, read from the same files +/// tools/Export-IpBlocklist.ps1 subtracts at generation time. +/// +/// +/// The generator already subtracts these from the blocklist, but that only covers being BLOCKED. +/// Behavioural detections never consult the blocklist, so without reading the files here a carve-out is +/// quietly routed around: one scanner behind a shared CGNAT address is enough to get the whole address +/// contributed and firewalled. Reading them also means an entry applies on the next reload rather than the +/// next regeneration. Opt-in via ip-allowlist.json's enabled, since the poll runs for the +/// whole uptime; no shield against a manual ban either — see . +/// +public static class ManualAllowlist +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ManualAllowlist)); + + // Written by the reload poll (off-loop), read by the accept path (game loop). One volatile reference + // swap is the whole synchronization story: readers see the old or the new snapshot, whole. + private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty; + + private static string[] _patterns = []; + private static TimeSpan _interval = TimeSpan.FromSeconds(60); + private static long _lastStamp; + private static CancellationTokenSource _cts; + + public static int Count => _snapshot.Count; + + /// True when an operator listed this address. Safe before . + public static bool Contains(IPAddress address) => address != null && _snapshot.Contains(address); + + /// True when the shard is reading allowlist files. Safe before . + public static bool Enabled { get; private set; } + + public static void Initialize() + { + ManualAllowlistConfiguration.Load(); + var settings = ManualAllowlistConfiguration.Settings; + if (settings == null) + { + return; + } + + _patterns = ResolvePaths(settings.Files); + _interval = settings.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : settings.ReloadInterval; + + if (!settings.Enabled) + { + // The generator still subtracts these files, so a carve-out an operator already wrote looks + // like it works right up until a behavioural detection contributes the address anyway. + var present = ExpandPaths().Length; + _patterns = []; + + if (present > 0) + { + logger.Warning( + "Manual allowlist is off (\"enabled\" false in ip-allowlist.json) but {Count} allowlist file(s) " + + "are present; those carve-outs will not suppress ban contributions", + present + ); + } + else + { + logger.Information("Manual allowlist disabled (\"enabled\" false in ip-allowlist.json)"); + } + + return; + } + + if (_patterns.Length == 0) + { + logger.Information("Manual allowlist disabled (\"files\" empty in ip-allowlist.json)"); + return; + } + + Enabled = true; + Reload(); + + _cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token); + _ = Task.Run(() => PollLoop(_cts.Token), _cts.Token); + } + + public static void Stop() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + private static string[] ResolvePaths(string[] configured) + { + if (configured == null) + { + return []; + } + + var resolved = new string[configured.Length]; + var count = 0; + + for (var i = 0; i < configured.Length; i++) + { + var path = configured[i]; + if (string.IsNullOrWhiteSpace(path)) + { + continue; + } + + // Relative resolves against BaseDirectory, never the working directory, which differs when the + // shard is launched from elsewhere. Absolute is as-is, so shards can share a list. + resolved[count++] = Path.IsPathRooted(path) ? path : Path.Join(Core.BaseDirectory, path); + } + + Array.Resize(ref resolved, count); + return resolved; + } + + /// + /// Expands the configured patterns to actual files. Done per poll rather than once, so a carve-out an + /// admin adds is picked up without a restart. + /// + private static string[] ExpandPaths() + { + var files = new List(); + + for (var i = 0; i < _patterns.Length; i++) + { + var pattern = _patterns[i]; + var name = Path.GetFileName(pattern); + + if (name.IndexOf('*') < 0 && name.IndexOf('?') < 0) + { + if (File.Exists(pattern)) + { + files.Add(pattern); + } + + continue; + } + + try + { + var dir = Path.GetDirectoryName(pattern); + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + { + continue; + } + + var matches = Directory.GetFiles(dir, name); + Array.Sort(matches, StringComparer.Ordinal); + + for (var j = 0; j < matches.Length; j++) + { + // Windows wildcard matching still honours legacy short names, so ".txt" can pull in the + // generator's ".txt.tmp" mid-swap. Check the real extension. + if (matches[j].EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + { + files.Add(matches[j]); + } + } + } + catch + { + // Unreadable directory; the next poll retries. + } + } + + return files.ToArray(); + } + + private static async ValueTask PollLoop(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + await Task.Delay(_interval, token); + } + catch (OperationCanceledException) + { + return; + } + + try + { + if (Stamp() != _lastStamp) + { + // A save owns the disk and nothing here is urgent. + // See the threading policy in CLAUDE.md (rules #3 and #10). + while (World.Saving || World.WorldState == WorldState.PendingSave) + { + await Task.Delay(TimeSpan.FromSeconds(1), token); + } + + Reload(); + } + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) + { + logger.Warning(e, "Manual allowlist reload check failed; keeping last snapshot ({Count})", Count); + } + } + } + + /// + /// Change fingerprint across every configured file. A missing file contributes nothing, so creating or + /// deleting one also registers as a change. + /// + private static long Stamp() + { + var stamp = 0L; + var paths = ExpandPaths(); + + for (var i = 0; i < paths.Length; i++) + { + try + { + var info = new FileInfo(paths[i]); + if (info.Exists) + { + stamp = stamp * 31 + info.LastWriteTimeUtc.Ticks + info.Length; + } + } + catch + { + // Mid-swap by the generator; the next poll picks it up. + } + } + + return stamp; + } + + private static void Reload() + { + // Fingerprint BEFORE parsing, so it describes the version being read. Capturing after could skip a + // version; a stale fingerprint only costs an extra reload. + var stamp = Stamp(); + var combined = ReadAll(out var files); + + // Reuses the blocklist parser and interval index: an address set is direction-agnostic. + var next = combined.Length == 0 + ? BlocklistSnapshot.Empty + : BlocklistSnapshot.Build(combined, out _, out _); + + _snapshot = next; // single volatile swap; readers see old or new whole + _lastStamp = stamp; + + logger.Information( + "Manual allowlist loaded {Count} range(s) from {Files} file(s)", + next.Count, + files + ); + } + + /// + /// Concatenates every configured file into one buffer. The parser is line-based, so a newline join is + /// enough, and membership stays a single lookup. + /// + private static byte[] ReadAll(out int files) + { + files = 0; + + var paths = ExpandPaths(); + var chunks = new byte[paths.Length][]; + var total = 0; + + for (var i = 0; i < paths.Length; i++) + { + try + { + if (!File.Exists(paths[i])) + { + continue; + } + + var bytes = File.ReadAllBytes(paths[i]); + chunks[i] = bytes; + total += bytes.Length + 1; // + newline separator + files++; + } + catch (Exception e) + { + // Fail open per file: losing one entry beats refusing to load the rest. + logger.Warning(e, "Could not read allowlist \"{Path}\"", paths[i]); + } + } + + if (total == 0) + { + return []; + } + + var combined = new byte[total]; + var offset = 0; + + for (var i = 0; i < chunks.Length; i++) + { + var chunk = chunks[i]; + if (chunk == null) + { + continue; + } + + Buffer.BlockCopy(chunk, 0, combined, offset, chunk.Length); + offset += chunk.Length; + combined[offset++] = (byte)'\n'; + } + + return combined; + } + + internal static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot ?? BlocklistSnapshot.Empty; +} diff --git a/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs new file mode 100644 index 000000000..dd21fe97c --- /dev/null +++ b/Projects/UOContent/Network/ManualAllowlist/ManualAllowlistConfiguration.cs @@ -0,0 +1,83 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: ManualAllowlistConfiguration.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.IO; +using System.Text.Json.Serialization; +using Server.Json; + +namespace Server.Network.Bans; + +/// +/// Loads the from Configuration/ip-allowlist.json. Loaded once; +/// a missing file writes a template so operators have something to edit. +/// +public static class ManualAllowlistConfiguration +{ + private const string _path = "Configuration/ip-allowlist.json"; + + public static ManualAllowlistSettings Settings { get; private set; } + + public static void Load() + { + var path = Path.Join(Core.BaseDirectory, _path); + + if (File.Exists(path)) + { + Settings = JsonConfig.Deserialize(path); + } + else + { + Settings = new ManualAllowlistSettings(); + Save(); + } + } + + private static void Save() + { + JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings); + } +} + +/// +/// Bound configuration for . Its own file rather than a corner of +/// blocklist.json: the blocklist is only one of two consumers, and the other +/// () works on a shard that runs no blocklist at all. +/// +public record ManualAllowlistSettings +{ + /// + /// Whether the shard reads at all. Off by default: reading them costs a poll for + /// the whole uptime, which no shard should pay before an operator has written a carve-out. + /// + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } + + /// + /// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same + /// files tools/Export-IpBlocklist.ps1 subtracts at generation time; the shard reads them so an + /// entry also suppresses ban contributions, which the generator alone cannot do. + /// + /// + /// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds + /// without anyone editing this file. + /// + [JsonPropertyName("files")] + public string[] Files { get; set; } = ["Configuration/ip-allowlist*.txt"]; + + /// How often the files are checked for changes. Reloads only happen when one actually changed. + [JsonPropertyName("reloadInterval")] + public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs index 07a444d03..792ed880b 100644 --- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs @@ -17,6 +17,9 @@ using System; using System.Buffers; using System.Collections.Generic; using System.IO; +using System.Net; +using System.Security.Cryptography; +using Server.Accounting; using Server.Engines.CharacterCreation; using Server.Misc; using Server.Mobiles; @@ -25,7 +28,16 @@ namespace Server.Network; public static class IncomingAccountPackets { + // Initial capacity and the point at which issuing sweeps expired ids. Not a cap; the window + // grows rather than evicting a live id. private const int _authIDWindowSize = 128; + + private static int _authIdPurgeThreshold = _authIDWindowSize; + + // The gap between PlayServerAck and the game login is seconds. Bounds how long a stolen id + // stays usable. + private static readonly TimeSpan _authIDLifetime = TimeSpan.FromMinutes(2.0); + private static readonly Dictionary _authIDWindow = new(_authIDWindowSize); @@ -34,13 +46,33 @@ public static class IncomingAccountPackets public DateTime Age; public readonly ClientVersion Version; - public AuthIDPersistence(ClientVersion v) + // GameLogin skips its password verify when both match, so the id is a bearer token and has + // to be bound to whatever earned it. + public readonly IAccount Account; + public readonly IPAddress Address; + + public AuthIDPersistence(ClientVersion v, IAccount account, IPAddress address) { Age = Core.Now; Version = v; + Account = account; + Address = Utility.Intern(address); } } + internal enum AuthIdResult + { + // No such id, or it was issued for a different account or address. + Rejected, + + // Right account and address, too old to stand in for the verify. Idling on the server list + // is normal, so this falls back to the password check rather than becoming a lockout. + Expired, + + // Issued to this account, from this address, recently. Stands in for the password verify. + Vouched + } + public static unsafe void Configure() { IncomingPackets.Register(0x00, &CreateCharacter, 104, outgameOnly: true); @@ -312,42 +344,92 @@ public static class IncomingAccountPackets } } - private static int GenerateAuthID(this NetState state) + private static int GenerateAuthID(this NetState state) => + EnsureAuthId(state.AuthId, state.Account, state.Address, state.Version); + + /// + /// One id per connection, by construction. Choosing a server queues a disconnect that is not + /// drained until the next slice, so a client pipelining another select into the same buffer + /// arrives here again; handing back the id it already holds cannot orphan one. + /// + internal static int EnsureAuthId(int existingAuthId, IAccount account, IPAddress address, ClientVersion version) + => existingAuthId != 0 ? existingAuthId : RegisterAuthId(account, address, version); + + internal static int RegisterAuthId(IAccount account, IPAddress address, ClientVersion version) { - if (_authIDWindow.Count == _authIDWindowSize) + // Sweep the ids left behind by clients that picked a server and never arrived, but never + // evict a live one to make room -- the client holding it is on its way to redeem it. If all + // are live the window grows, which is a login rush, not a backlog. Each entry costs a + // successful password verify, so the size is self-limiting. + if (_authIDWindow.Count >= _authIdPurgeThreshold) { - var oldestID = 0; - var oldest = DateTime.MaxValue; - - foreach (var (key, authId) in _authIDWindow) - { - if (authId.Age < oldest) - { - oldestID = key; - oldest = authId.Age; - } - } - - _authIDWindow.Remove(oldestID); + PurgeExpiredAuthIds(); + _authIdPurgeThreshold = Math.Max(_authIDWindowSize, _authIDWindow.Count * 2); } int authID; + // The id stands in for a password verify, so it has to be unguessable. Zero is reserved: + // GameLogin reads state.AuthId == 0 as "no auth id was issued". do { - authID = Utility.Random(1, int.MaxValue - 1); + authID = RandomNumberGenerator.GetInt32(int.MinValue, int.MaxValue); + } while (authID == 0 || _authIDWindow.ContainsKey(authID)); - if (Utility.RandomBool()) - { - authID |= 1 << 31; - } - } while (_authIDWindow.ContainsKey(authID)); - - _authIDWindow[authID] = new AuthIDPersistence(state.Version); + _authIDWindow[authID] = new AuthIDPersistence(version, account, address); return authID; } + /// + /// Spends an auth id, but only for the account and address it was issued to. An address + /// mismatch is rather than a fallback: network switching + /// mid-login is not supported. + /// + internal static AuthIdResult ConsumeAuthId(int authId, string username, IPAddress address, out AuthIDPersistence entry) + { + if (!_authIDWindow.TryGetValue(authId, out entry)) + { + return AuthIdResult.Rejected; + } + + // Look, then take: removing before ownership is proven would let anyone landing on a live id + // burn it, leaving its owner to log in again. Address before username, so a remote guesser + // never learns whether a username matched. + if (!Utility.Intern(address).Equals(entry.Address) + || entry.Account == null || !username.InsensitiveEquals(entry.Account.Username)) + { + entry = default; + return AuthIdResult.Rejected; + } + + // Theirs, so spend it. Expired counts as spent; it has done all it is ever going to do. + _authIDWindow.Remove(authId); + + return Core.Now - entry.Age > _authIDLifetime ? AuthIdResult.Expired : AuthIdResult.Vouched; + } + + private static void PurgeExpiredAuthIds() + { + var now = Core.Now; + + foreach (var (key, entry) in _authIDWindow) + { + if (now - entry.Age > _authIDLifetime) + { + _authIDWindow.Remove(key); + } + } + } + + internal static void ClearAuthIdWindow() + { + _authIDWindow.Clear(); + _authIdPurgeThreshold = _authIDWindowSize; + } + + internal static int AuthIdWindowCount => _authIDWindow.Count; + public static void GameLogin(NetState state, SpanReader reader) { if (state.SentFirstPacket) @@ -360,12 +442,6 @@ public static class IncomingAccountPackets var authId = reader.ReadInt32(); - if (!_authIDWindow.TryGetValue(authId, out var ap)) - { - state.LogInfo("Invalid client detected, disconnecting..."); - state.Disconnect("Unable to find auth id."); - } - if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed) { state.LogInfo("Invalid client detected, disconnecting..."); @@ -373,14 +449,28 @@ public static class IncomingAccountPackets return; } - _authIDWindow.Remove(authId); - state.Version = ap.Version; - state.Seeded = true; - var username = reader.ReadLatin1Safe(30); var password = reader.ReadLatin1Safe(30); - var e = new GameServer.GameLoginEventArgs(state, username, password); + var authResult = ConsumeAuthId(authId, username, state.Address, out var ap); + + if (authResult == AuthIdResult.Rejected) + { + state.LogInfo("Invalid client detected, disconnecting..."); + state.Disconnect("Unable to find auth id."); + return; + } + + state.Version = ap.Version; + state.Seeded = true; + + // Expired carries a usable entry; only the password verify skip is withheld. + var e = new GameServer.GameLoginEventArgs( + state, + username, + password, + authResult == AuthIdResult.Vouched + ); GameServer.GameServerLoginEvent(e); @@ -402,6 +492,14 @@ public static class IncomingAccountPackets public static void PlayServer(NetState state, SpanReader reader) { + // A server is picked once per connection. Picking again hands back an id this connection may + // already have spent on a game login, which the client could never redeem. + if (state.AuthId != 0) + { + state.Disconnect("Duplicate play server packet sent."); + return; + } + int index = reader.ReadInt16(); var info = state.ServerInfo; var a = state.Account; @@ -414,7 +512,7 @@ public static class IncomingAccountPackets { var si = info[index]; - state.AuthId = GenerateAuthID(state); + state.AuthId = state.GenerateAuthID(); state.SentFirstPacket = false; state.SendPlayServerAck(si, state.AuthId); @@ -423,6 +521,14 @@ public static class IncomingAccountPackets public static void LoginServerSeed(NetState state, SpanReader reader) { + // Seeding happens once per connection. A second one restarts a handshake this connection + // already completed, which no real client does. + if (state.Seeded) + { + state.Disconnect("Duplicate login server seed packet sent."); + return; + } + state.Seed = reader.ReadInt32(); state.Seeded = true; @@ -458,7 +564,22 @@ public static class IncomingAccountPackets EventSink.InvokeAccountLogin(accountLoginEventArgs); - if (accountLoginEventArgs.Accepted) + // The password check moved off the loop; whoever took it replies when the verdict lands. + if (accountLoginEventArgs.Deferred) + { + return; + } + + CompleteAccountLogin(state, accountLoginEventArgs.Accepted, accountLoginEventArgs.RejectReason); + } + + /// + /// Replies to an account login. Split out so a verdict produced off the loop reaches the client + /// through exactly the same path as one produced inline. + /// + internal static void CompleteAccountLogin(NetState state, bool accepted, ALRReason rejectReason) + { + if (accepted) { var serverListEventArgs = new GatewayServer.ServerListEventArgs(state, state.Account); @@ -478,7 +599,7 @@ public static class IncomingAccountPackets else { state.Account = null; - AccountLogin_ReplyRej(state, accountLoginEventArgs.RejectReason); + AccountLogin_ReplyRej(state, rejectReason); } } diff --git a/Projects/UOContent/Regions/BaseRegion.cs b/Projects/UOContent/Regions/BaseRegion.cs index b18c7fa18..776ec269d 100644 --- a/Projects/UOContent/Regions/BaseRegion.cs +++ b/Projects/UOContent/Regions/BaseRegion.cs @@ -113,7 +113,7 @@ public class BaseRegion : Region m_RectBuffer2.RemoveAt(k); var sz = rect.Start.Z; - var ez = rect.End.X; + var ez = rect.End.Z; if (l1 < l2) { diff --git a/Projects/UOContent/Skills/AntiMacroSystem.cs b/Projects/UOContent/Skills/AntiMacroSystem.cs index 60fcc899f..e37431ddd 100644 --- a/Projects/UOContent/Skills/AntiMacroSystem.cs +++ b/Projects/UOContent/Skills/AntiMacroSystem.cs @@ -120,23 +120,17 @@ public static class AntiMacroSystem var now = Core.Now; - using var toRemove = PooledRefQueue.Create(); foreach (var (m, antiMacro) in _antiMacroTable) { if (antiMacro._lastExpiration <= now) { - toRemove.Enqueue(m); + _antiMacroTable.Remove(m); } else { antiMacro.CleanExpired(); } } - - while (toRemove.Count > 0) - { - _antiMacroTable.Remove(toRemove.Dequeue()); - } } [OnEvent(nameof(PlayerMobile.PlayerLoginEvent))] @@ -259,20 +253,13 @@ public static class AntiMacroSystem { var now = Core.Now; - using var toRemove = PooledRefQueue<(Skill, object)>.Create(); - foreach (var (key, countAndTimeStamp) in _antiMacroTracking) { if (countAndTimeStamp._count <= 0 || countAndTimeStamp._expiration <= now) { - toRemove.Enqueue(key); + _antiMacroTracking.Remove(key); } } - - while (toRemove.Count > 0) - { - _antiMacroTracking.Remove(toRemove.Dequeue()); - } } } diff --git a/Projects/UOContent/Skills/DetectHidden.cs b/Projects/UOContent/Skills/DetectHidden.cs index b1b0a02fb..487eda693 100644 --- a/Projects/UOContent/Skills/DetectHidden.cs +++ b/Projects/UOContent/Skills/DetectHidden.cs @@ -39,20 +39,13 @@ public static class DetectHidden // Clean up old debounce entries to prevent memory bloat private static void CleanupDebounceCache(long now) { - using var entriesToRemove = PooledRefQueue<(Mobile, Mobile)>.Create(); - foreach (var entry in PassiveDetectDebounce) { if (now - entry.Value > DebounceExpiryMs) { - entriesToRemove.Enqueue(entry.Key); + PassiveDetectDebounce.Remove(entry.Key); } } - - while (entriesToRemove.Count > 0) - { - PassiveDetectDebounce.Remove(entriesToRemove.Dequeue()); - } } // For testing: clear the debounce cache to prevent cross-test contamination diff --git a/Projects/UOContent/Spells/Fifth/PoisonField.cs b/Projects/UOContent/Spells/Fifth/PoisonField.cs index 3c5d2f609..92a797f19 100644 --- a/Projects/UOContent/Spells/Fifth/PoisonField.cs +++ b/Projects/UOContent/Spells/Fifth/PoisonField.cs @@ -64,13 +64,19 @@ public class PoisonFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class PoisonField : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Fourth/FireField.cs b/Projects/UOContent/Spells/Fourth/FireField.cs index 0b0a22129..8f20f995b 100644 --- a/Projects/UOContent/Spells/Fourth/FireField.cs +++ b/Projects/UOContent/Spells/Fourth/FireField.cs @@ -67,16 +67,23 @@ public class FireFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class FireFieldItem : Item { + private void MigrateFrom(V0Content content) + { + _damage = content.Damage; + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private int _damage; [SerializableField(1)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(2)] private DateTime _end; private Timer _timer; diff --git a/Projects/UOContent/Spells/Seventh/EnergyField.cs b/Projects/UOContent/Spells/Seventh/EnergyField.cs index 5e3ea1149..dbfece36d 100644 --- a/Projects/UOContent/Spells/Seventh/EnergyField.cs +++ b/Projects/UOContent/Spells/Seventh/EnergyField.cs @@ -77,13 +77,19 @@ public class EnergyFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class EnergyField : Item { + private void MigrateFrom(V1Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs index 830f16dfe..301cee507 100644 --- a/Projects/UOContent/Spells/Sixth/ParalyzeField.cs +++ b/Projects/UOContent/Spells/Sixth/ParalyzeField.cs @@ -77,13 +77,19 @@ public class ParalyzeFieldSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class ParalyzeField : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs index 54ac52030..49894cc95 100644 --- a/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs +++ b/Projects/UOContent/Spells/Spellweaving/Items/TransientItem.cs @@ -3,12 +3,17 @@ using ModernUO.Serialization; namespace Server.Items; -[SerializationGenerator(1, false)] +[SerializationGenerator(2, false)] public partial class TransientItem : Item { + private void MigrateFrom(V1Content content) + { + _expiration = content.Expiration; + } + private TimerExecutionToken _timerToken; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(0)] [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _expiration; diff --git a/Projects/UOContent/Spells/Third/WallOfStone.cs b/Projects/UOContent/Spells/Third/WallOfStone.cs index 7cae3b848..1b1dd04d3 100644 --- a/Projects/UOContent/Spells/Third/WallOfStone.cs +++ b/Projects/UOContent/Spells/Third/WallOfStone.cs @@ -63,13 +63,19 @@ public class WallOfStoneSpell : MagerySpell, ITargetingSpell } [DispellableField] -[SerializationGenerator(0, false)] +[SerializationGenerator(1, false)] public partial class WallOfStone : Item { + private void MigrateFrom(V0Content content) + { + _caster = content.Caster; + _end = content.End; + } + [SerializableField(0)] private Mobile _caster; - [DeltaDateTime] + [AnchoredDateTime] [SerializableField(1)] private DateTime _end; diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 4f230902d..60f28ac6f 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -40,19 +40,18 @@ false - - + + - + - + - - - + + diff --git a/Projects/UOContent/Utilities/Types.cs b/Projects/UOContent/Utilities/Types.cs index 810ff0310..ca5b9422d 100644 --- a/Projects/UOContent/Utilities/Types.cs +++ b/Projects/UOContent/Utilities/Types.cs @@ -1,5 +1,5 @@ using System; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.Globalization; using System.Reflection; using System.Runtime.CompilerServices; @@ -10,7 +10,8 @@ namespace Server { public static readonly Type[] ParseStringParamTypes = { typeof(string), typeof(IFormatProvider) }; public static readonly Type[] ParseStringNumericParamTypes = { typeof(string), typeof(NumberStyles) }; - private static object[] _parseParams = { null, null }; + // Legacy RunUO signature: a static Parse(string) that predates IParsable (e.g. Faction, Town). + public static readonly Type[] ParseStringSingleParamTypes = { typeof(string) }; public static readonly Type OfByte = typeof(byte); public static readonly Type OfSByte = typeof(sbyte); @@ -75,7 +76,9 @@ namespace Server OfULong }; - private static Dictionary _isParsable; + // Thread-safe: parse metadata is read from parallel callers (e.g. the Advanced Search workers), + // not just the single-threaded command path. + private static readonly ConcurrentDictionary _isParsable = new(); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsType(Type type, Type check) => check.IsAssignableFrom(type); @@ -89,25 +92,19 @@ namespace Server [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsText(Type t) => IsType(t, OfText); - public static bool IsParsable(Type t) - { - _isParsable ??= new(); - if (_isParsable.TryGetValue(t, out var isParsable)) + public static bool IsParsable(Type t) => + _isParsable.GetOrAdd(t, static type => { - return isParsable; - } - - foreach (var x in t.GetInterfaces()) - { - if (x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IParsable<>)) + foreach (var x in type.GetInterfaces()) { - isParsable = true; - break; + if (x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IParsable<>)) + { + return true; + } } - } - return _isParsable[t] = isParsable; - } + return false; + }); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsDecimal(Type t) => Array.IndexOf(DecimalTypes, t) >= 0; @@ -118,18 +115,81 @@ namespace Server [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsEntity(Type t) => OfEntity.IsAssignableFrom(t); - private static Dictionary _parseMethods; + private static readonly ConcurrentDictionary _parseMethods = new(); + + // A static string Parse method: the modern IParsable Parse(string, IFormatProvider), or a + // legacy RunUO Parse(string). Cached per type; null if the type has neither. (Span-based Parse + // can't be reflection-invoked — a ReadOnlySpan can't be boxed into the args array — so the + // string overloads are what we bind to.) + public static MethodInfo GetParseMethod(Type t) => + _parseMethods.GetOrAdd( + t, + static type => type.GetMethod("Parse", ParseStringParamTypes) + ?? type.GetMethod("Parse", ParseStringSingleParamTypes) + ); public static object Parse(Type t, string value) { - _parseMethods ??= new(); - if (!_parseMethods.TryGetValue(t, out var method)) + var method = GetParseMethod(t); + if (method == null) { - _parseMethods[t] = method = t.GetMethod("Parse", ParseStringParamTypes); + return null; } - _parseParams[0] = value; - return method?.Invoke(null, _parseParams); + // Fresh args array per call — a shared static array would race across concurrent callers. + // Arg shape depends on which overload we bound to (IParsable 2-arg vs legacy 1-arg). + var args = method.GetParameters().Length == 2 ? new object[] { value, null } : new object[] { value }; + return method.Invoke(null, args); + } + + // Parses directly into the concrete numeric type via INumber.TryParse (the Type-dispatched + // equivalent of a generic TryParse). Returns the boxed value; false if the text doesn't fit + // the type's range/format so the caller can fall through. + private static bool TryParseNumeric(Type type, ReadOnlySpan span, NumberStyles style, out object result) + { + if (type == OfInt && int.TryParse(span, style, null, out var i)) + { + result = i; + return true; + } + if (type == OfUInt && uint.TryParse(span, style, null, out var ui)) + { + result = ui; + return true; + } + if (type == OfLong && long.TryParse(span, style, null, out var l)) + { + result = l; + return true; + } + if (type == OfULong && ulong.TryParse(span, style, null, out var ul)) + { + result = ul; + return true; + } + if (type == OfShort && short.TryParse(span, style, null, out var s)) + { + result = s; + return true; + } + if (type == OfUShort && ushort.TryParse(span, style, null, out var us)) + { + result = us; + return true; + } + if (type == OfByte && byte.TryParse(span, style, null, out var b)) + { + result = b; + return true; + } + if (type == OfSByte && sbyte.TryParse(span, style, null, out var sb)) + { + result = sb; + return true; + } + + result = null; + return false; } // Do not use this in "Parse" methods, it may cause a stack overflow @@ -201,35 +261,38 @@ namespace Server if (IsNumeric(type)) { - try + var span = value.AsSpan(); + var style = NumberStyles.Integer; + if (span.StartsWithOrdinal("0x")) { - var isHex = value.StartsWithOrdinal("0x"); - var index = isHex ? 2 : 0; - if (ulong.TryParse(value.AsSpan(index), isHex ? NumberStyles.HexNumber : NumberStyles.Integer, null, out var num)) - { - if (isEntity) - { - constructed = World.FindEntity((Serial)num); - } - else if (isSerial) - { - constructed = (Serial)num; - } - else - { - constructed = Convert.ChangeType(num, type); - } + span = span[2..]; + style = NumberStyles.HexNumber; + } + if (isEntity || isSerial) + { + // Serial/entity properties were mutated to int above; a Serial is a uint, so parse + // the full 32-bit range as ulong and resolve. + if (ulong.TryParse(span, style, null, out var num)) + { + constructed = isEntity ? World.FindEntity((Serial)num) : (Serial)num; return null; } } - catch + else if (TryParseNumeric(type, span, style, out constructed)) { - return "That is not properly formatted."; + // Parse the string directly into the target type via INumber.TryParse — no + // Convert.ChangeType, and (unlike parse-as-ulong) signed and per-type ranges are honored. + return null; } + + // On parse failure, fall through to the Parse-method / Convert.ChangeType fallbacks below. } - if (IsParsable(type)) + // IParsable (Parse(string, IFormatProvider)) or a legacy RunUO Parse(string). Gating on + // the discovered method rather than the IParsable interface keeps pre-IParsable types + // (Faction, Town, ...) parseable for backwards compatibility. + if (GetParseMethod(type) != null) { try { diff --git a/README.md b/README.md index 5bc8e7985..01a368804 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc ## Requirements #### Supported Operating Systems -[![Windows 10/11/2012/2016/2019/2022/2025](https://img.shields.io/badge/-server%202025-3c78d5?labelColor=222222&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHJvbGU9ImltZyIgdmlld0JveD0iMCAwIDI0IDI0Ij48dGl0bGU+V2luZG93czwvdGl0bGU+PHBhdGggZD0iTTAsMEgxMS4zNzdWMTEuMzcySDBaTTEyLjYyMywwSDI0VjExLjM3MkgxMi42MjNaTTAsMTIuNjIzSDExLjM3N1YyNEgwWm0xMi42MjMsMEgyNFYyNEgxMi42MjMiIGZpbGw9IiMzYzc4ZDUiLz48L3N2Zz4=)](https://www.microsoft.com/en-US/evalcenter/evaluate-windows-server-2022) +[![Windows 10/11/2012 R2/2016/2019/2022/2025](https://img.shields.io/badge/-server%202025-3c78d5?labelColor=222222&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHJvbGU9ImltZyIgdmlld0JveD0iMCAwIDI0IDI0Ij48dGl0bGU+V2luZG93czwvdGl0bGU+PHBhdGggZD0iTTAsMEgxMS4zNzdWMTEuMzcySDBaTTEyLjYyMywwSDI0VjExLjM3MkgxMi42MjNaTTAsMTIuNjIzSDExLjM3N1YyNEgwWm0xMi42MjMsMEgyNFYyNEgxMi42MjMiIGZpbGw9IiMzYzc4ZDUiLz48L3N2Zz4=)](https://www.microsoft.com/en-US/evalcenter/evaluate-windows-server-2022) ![MacOS 14+](https://img.shields.io/badge/-sonoma-222222?logo=apple&logoColor=white&labelColor=222222) [![Debian 12+](https://img.shields.io/badge/-trixie-A81D33?logo=debian&logoColor=A81D33&labelColor=222222)](https://www.debian.org/distrib/) [![Ubuntu 22+ LTS](https://img.shields.io/badge/-26LTS-E95420?logo=ubuntu&logoColor=E95420&labelColor=222222)](https://ubuntu.com/download/server) @@ -37,6 +37,17 @@ ModernUO [![Discord](https://img.shields.io/discord/751317910504603701?logo=disc ##### Windows [![VC++ Redistributable v14](https://img.shields.io/badge/-Redist%20v14-00599C?logo=cplusplus&logoColor=white&labelColor=222222)](https://aka.ms/vc14/vc_redist.x64.exe) +#### Hardware + +| Use | vCPU | RAM | Storage | +|---|---|---|---| +| Development / test | 2 **dedicated** | 2 GB | SSD | +| Small live shard (< 50 concurrent) | 4 dedicated | 4 GB | NVMe | +| Medium (50–200) | 4–8 | 8 GB | NVMe | +| Large (200+) | 8+, high clock | 16 GB+ | NVMe | + +See [dev-docs/server-requirements.md](dev-docs/server-requirements.md) for more information. + #### Development [![git](https://img.shields.io/badge/-git-F05032?logo=git&logoColor=F05032&labelColor=222222)](https://git-scm.com/downloads) [![.NET](https://img.shields.io/badge/-%2010.0.100%20SDK-5C2D91?logo=.NET&logoColor=white&labelColor=222222)](https://dotnet.microsoft.com/download/dotnet/10.0) @@ -87,18 +98,31 @@ dnf install -y dnf-plugins-core dnf config-manager --set-enabled crb dnf install -y epel-release # Prerequisites -dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel liburing-devel +dnf install -y findutils libicu libdeflate libargon2 tzdata ``` ### Ubuntu, Debian, etc ```shell apt-get update -y -apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev liburing-dev +# The ICU runtime package carries the ABI version in its name (libicu74, libicu76, …) and has no +# stable alias, so match it by pattern rather than pinning a release-specific name. +apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata ``` +Only the runtime libraries are needed — the `-dev`/`-devel` packages are not. Run +`./build-tool --check-prereqs` to check the current machine and print the exact packages your +release needs. + +`zstd` is not listed because ZstdNet bundles `libzstd` for every platform, and `liburing` is not +listed because IORingGroup issues `io_uring` syscalls directly. + +If the shard's configured time zone is a legacy alias such as `US/Eastern`, Debian 12 and Ubuntu +24.04 also need `tzdata-legacy`. See [Platform Prerequisites](dev-docs/platform-prerequisites.md) +for what each dependency is for and what breaks without it. + ## OSX Requirements ```shell -brew install icu4c libdeflate zstd argon2 +brew install icu4c libdeflate argon2 ``` ## Running the Server diff --git a/dev-docs/claude-skills/modernuo-code-audit.md b/dev-docs/claude-skills/modernuo-code-audit.md index 39a8d4983..d805b0578 100644 --- a/dev-docs/claude-skills/modernuo-code-audit.md +++ b/dev-docs/claude-skills/modernuo-code-audit.md @@ -191,8 +191,36 @@ mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" not "Gold" **See**: `dev-docs/string-handling.md` § "Interpolation Anti-Patterns" for the full reference with detailed before/after examples. +### 19. No InvalidateProperties From Inside GetProperties +**Check**: Any property read by a `GetProperties` override — including through helpers — must be a pure read. Flag getters that call `InvalidateProperties()` (or a wrapper like `Invalidate()`) as a side effect. +**Bad**: a `Rank` getter that lazily recomputes and then calls `Invalidate()`; reading it from `GetProperties` re-enters the build. +**Good**: invalidate in the setter that actually changes the value, or defer with `Timer.DelayCall(InvalidateProperties)`. +**Why**: `InvalidateProperties()` rebuilds the list in place (`Reset()` + rebuild). `Reset()` returns the pooled interpolation buffer — which the compiler rents for the whole `$"..."` expression, so every hole is evaluated while it is live — and rewinds the packet cursor. Re-entering mid-build throws `ArgumentNullException` (parameter `"array"`) out of `GetProperties` from a line unrelated to the offending getter, or silently corrupts the tooltip. The engine refuses and logs an error, and `DEBUG` throws, so this shows up as a crash in development. +**Note**: Lazy recomputation inside a getter is fine. It is the notification that must not happen there. + +**See**: `dev-docs/property-lists.md` § "Never Invalidate From Inside `GetProperties`". + +### 20. Tick-Count Math Must Be Wraparound-Safe +**Check**: Every comparison between `Core.TickCount` / `Core.GetTimestamp()` values (or fields +derived from them — names like `*Until`, `*At`, `*Next*`, `deadline`) must be in subtraction form. +Flag direct comparisons, zero/sign sentinels, and deadline fields left at their zero default. +**Bad**: `if (Core.TickCount < _deadline)`; `if (_lastEventAt > 0)` as "has happened"; +`private static long _deadline;` compared before being seeded from a real tick. +**Good**: `if (Core.TickCount - _deadline < 0)`; a separate `bool` for "has happened"; seeding +deadline fields from the first observed timestamp. +**Why**: On some hypervisors — Google Cloud specifically — the VM receives a pass-through of the +host's never-resetting counter. Tick counts are NOT zero at process start, NOT zero at OS boot, +can be enormous from the first read, and can wrap negative. Direct comparisons and sign sentinels +then fail only on those hosts, after long host uptimes — the least reproducible bug class there +is. Windows has not shown this in testing; Linux has, in production. Subtraction of two ticks +wraps correctly in two's complement. +**Note**: `DateTime`/`DateTimeOffset` comparisons are unaffected; this applies only to the +monotonic tick domain. + +**See**: `dev-docs/tick-counts.md` for the full rules and review checklist. + ## Severity Levels -- **ERROR**: Rules 3, 9, 10, 13 (will cause bugs, build failures, or client-side leaks) +- **ERROR**: Rules 3, 9, 10, 13, 19, 20 (will cause bugs, build failures, or client-side leaks) - **WARNING**: Rules 1 (Tier 3 LINQ), 2, 4, 5, 6, 7, 8, 12, 14, 15, 17 (performance/convention issues) - **INFO**: Rules 1 (Tier 2 LINQ on warm paths — note it but don't flag as violation), 16 (switch patterns — suggest but don't flag) - **ASK**: Rule 11 (need user input) diff --git a/dev-docs/claude-skills/modernuo-content-patterns.md b/dev-docs/claude-skills/modernuo-content-patterns.md index 12c9515ef..ce7f18acf 100644 --- a/dev-docs/claude-skills/modernuo-content-patterns.md +++ b/dev-docs/claude-skills/modernuo-content-patterns.md @@ -23,6 +23,12 @@ description: > 4. **Clean up timers and references in `OnDelete()`/`OnAfterDelete()`** 5. **No LINQ** in game logic -- use loops and `PooledRefList` 6. **File placement** matters -- follow the directory conventions below +7. **Creature speeds are delays in seconds, on two clocks** -- think + (`ActiveSpeed`/`PassiveSpeed`, seconds per AI decision) and move + (`ActiveMoveSpeed`/`PassiveMoveSpeed`, seconds per step; inherits think until + overridden). Prefer `npc-speeds.json` buckets (`SpeedClass`); `SetSpeed()` sets think + AND clears move overrides, `SetMoveSpeed()` sets move only -- see + `dev-docs/content-patterns.md` § Creature Speeds ## New Item Template diff --git a/dev-docs/claude-skills/modernuo-property-lists.md b/dev-docs/claude-skills/modernuo-property-lists.md index 4440c63db..51e47ff35 100644 --- a/dev-docs/claude-skills/modernuo-property-lists.md +++ b/dev-docs/claude-skills/modernuo-property-lists.md @@ -20,6 +20,12 @@ description: > 3. **String interpolation** works with `IPropertyList` -- use `$"..."` syntax 4. **`[InvalidateProperties]`** on `[SerializableField]` auto-refreshes tooltip on change 5. **Call `InvalidateProperties()`** manually when non-serialized state changes tooltip +6. **Never invalidate from inside `GetProperties`** -- every property a `GetProperties` override + reads must be a pure read. `InvalidateProperties()` rebuilds in place (`Reset()` + rebuild), so a + getter with that side effect tears down the list mid-build: it returns the pooled interpolation + buffer under an in-flight `$"..."` handler (`ArgumentNullException`, parameter `"array"`) and + rewinds the packet cursor. The engine refuses and logs an error; `DEBUG` throws. Defer instead: + `Timer.DelayCall(InvalidateProperties)` ## IPropertyList Interface @@ -235,6 +241,7 @@ block.Add("Cannot be repaired".AsSpan()); // plain span, no string alloc - **Excessive rebuilds**: Don't call `InvalidateProperties()` in tight loops - **Assuming tooltip support**: Check `ObjectPropertyList.Enabled` if needed - **One giant `Add()` for multi-line text**: A property over ~512 chars crashes the legacy 2D client. Use `AddChunked`/`OplTextBlock` for variable-length free text +- **Side-effecting property getters**: A getter reached from `GetProperties` that calls `InvalidateProperties()` (directly or via a helper like `Invalidate()`) re-enters the build and is refused — error logged, `DEBUG` throws. Lazy recomputation in a getter is fine; the *notification* is not. Invalidate where the value changes, or `Timer.DelayCall(InvalidateProperties)` ## Real Examples - Item properties: `Projects/Server/Items/Item.cs` (AddNameProperties, GetProperties) diff --git a/dev-docs/claude-skills/modernuo-serialization.md b/dev-docs/claude-skills/modernuo-serialization.md index c96244656..91e3d5e95 100644 --- a/dev-docs/claude-skills/modernuo-serialization.md +++ b/dev-docs/claude-skills/modernuo-serialization.md @@ -40,21 +40,31 @@ public partial class MyItem : Item { } public partial class MigratedItem : Item { } ``` -### [SerializableField(index, setter, saveIf)] +### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] Applied to `_camelCase` private fields. Generates `PascalCase` property. - `index`: Serialization order (0+) -- `setter`: Access level -- `"private"`, `"internal"`, or omit for public -- `saveIf`: Condition method name for conditional serialization +- `getter`/`setter`: Access level -- `"private"`, `"internal"`, or omit for public +- `isVirtual`: Generate a virtual property +- `fieldChanged`: `nameof` of `void Method(T oldValue, T newValue)`, invoked by the generated setter after assignment +- `allowFieldChange`: `nameof` of `bool Method(ref T value)`, invoked before assignment -- coerce through the `ref` parameter or return `false` to reject + +Generated setter pipeline: equality check → `allowFieldChange` → assignment → `MarkDirty` → `InvalidateProperties` (if declared) → `fieldChanged`. The field still holds the old value while the gate runs. Hooks require a generated setter (SG3018 on readonly/setterless fields); a missing or wrong-shaped named method is SG3015. ```csharp -[SerializableField(0)] +[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] [SerializedCommandProperty(AccessLevel.GameMaster)] +[InvalidateProperties] private int _charges; -// Generates: public int Charges { get; set; } + +private bool AllowChargesChange(ref int value) +{ + value = Math.Clamp(value, 0, MaxCharges); + return true; +} ``` ### [SerializableProperty(index, useField)] -Applied to properties with custom get/set logic. +Applied to properties with **custom getters** (fallback defaults, lazy/self-healing reads) or setter semantics the field hooks cannot express. For setters that only coerce, veto, or run post-change side effects, use `[SerializableField]` with `allowFieldChange`/`fieldChanged` instead. - `index`: Serialization order - `useField`: Backing field name if auto-detection fails @@ -63,12 +73,12 @@ Applied to properties with custom get/set logic. [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property set { _maxItems = value; InvalidateProperties(); - this.MarkDirty(); + this.MarkDirty(); // REQUIRED in custom setters } } ``` @@ -89,8 +99,11 @@ Exposes field to `[Props` gump for in-game editing. ### [EncodedInt] Variable-length int encoding (saves space for small values). +### [AnchoredDateTime] +Stores the absolute UTC instant; shifted by downtime at load so remaining time is preserved. Byte-stable across idle saves. Prefer for deadlines/elapsed-while-running values. + ### [DeltaDateTime] -Stores DateTime as offset from current time (handles server restarts). +Stores DateTime as offset from current time (handles server restarts). Legacy: rewrites bytes every save; prefer `[AnchoredDateTime]` for new fields. Converting between the two changes the wire format (version bump). ### [InternString] Interns strings to reduce memory for repeated values. @@ -128,28 +141,32 @@ private void AfterDeserialization() } ``` -### [DeserializeTimerField(fieldIndex)] -Custom timer deserialization. Timer is saved as remaining TimeSpan. +### [DeserializeTimer(nameof(Method), wallClock)] +Required on every serializable `Timer` member (SG3008 otherwise). By default the next tick is stored as **anchored time** (downtime does not consume the remaining delay; idle saves byte-stable); `wallClock: true` stores an absolute deadline instead (delay negative if it passed during downtime). The method -- `void Method(TimeSpan delay)` -- is invoked **only when a timer was running at save**; there is no sentinel to check. ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; -[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` -### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] -Conditional serialization -- skip fields with default values. +Switching a timer between drifting and `wallClock` changes the wire format: bump the class version and add `MigrateFrom` -- the old content struct exposes `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer ran). + +### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +On the serializable field/property itself. Conditional serialization -- skip fields with default values. Second method optional; when omitted, the field keeps its default at load. ```csharp -[SerializableFieldSaveFlag(0)] +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] +private int _maxItems; + private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -216,28 +233,35 @@ public partial class ChargedItem : Item } ``` -### Item with Custom Properties +### Item with Setter Hooks (coerce + side effects) ```csharp [SerializationGenerator(2)] public partial class BagOfSending : Item { - [SerializableProperty(0)] - [CommandProperty(AccessLevel.GameMaster)] - public BagOfSendingHue BagOfSendingHue + [SerializableField(0, fieldChanged: nameof(OnBagOfSendingHueChanged))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private BagOfSendingHue _bagOfSendingHue; + + private void OnBagOfSendingHueChanged(BagOfSendingHue oldValue, BagOfSendingHue newValue) { - get => _bagOfSendingHue; - set + Hue = newValue switch { - _bagOfSendingHue = value; - Hue = value switch - { - BagOfSendingHue.Yellow => 0x8A5, - BagOfSendingHue.Blue => 0x8AD, - BagOfSendingHue.Red => 0x89B, - _ => Hue - }; - this.MarkDirty(); - } + BagOfSendingHue.Yellow => 0x8A5, + BagOfSendingHue.Blue => 0x8AD, + BagOfSendingHue.Red => 0x89B, + _ => Hue + }; + } + + [SerializableField(1, allowFieldChange: nameof(AllowChargesChange))] + [SerializedCommandProperty(AccessLevel.GameMaster)] + [InvalidateProperties] + private int _charges; + + private bool AllowChargesChange(ref int value) + { + value = Math.Clamp(value, 0, MaxCharges); + return true; } } ``` @@ -328,11 +352,13 @@ public partial class MagicGem ## Real Examples - Simple creature: `Projects/UOContent/Mobiles/Animals/Bears/BlackBear.cs` - Serialized fields + timer: `Projects/UOContent/Items/Weapons/Ranged/BaseRanged.cs` -- Custom properties: `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` +- Setter hooks (allowFieldChange + fieldChanged): `Projects/UOContent/Items/Special/Solen Items/BagOfSending.cs` +- Custom getters (era fallbacks, the [SerializableProperty] use case): `Projects/UOContent/Items/Weapons/BaseWeapon.cs` - Complex with AfterDeserialization: `Projects/UOContent/Accounting/Account.cs` -- Timer deserialization: `Projects/UOContent/Items/Aquarium/Aquarium.cs` +- Timer deserialization (wall-clock): `Projects/UOContent/Items/Aquarium/Aquarium.cs` +- Timer deserialization (drifting/anchored + timer MigrateFrom): `Projects/UOContent/Items/Lights/BaseLight.cs` - Tidy + DeltaDateTime: `Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs` -- Conditional serialization: `Projects/Server/Items/Container.cs` +- Conditional serialization ([SaveFlag]): `Projects/Server/Items/Container.cs` ## Version Migration Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`: diff --git a/dev-docs/claude-skills/modernuo-threading.md b/dev-docs/claude-skills/modernuo-threading.md index 800bf77d2..3b6507af2 100644 --- a/dev-docs/claude-skills/modernuo-threading.md +++ b/dev-docs/claude-skills/modernuo-threading.md @@ -150,6 +150,78 @@ These files MAY use threading (they're server infrastructure, not game logic): - `Projects/Server/Network/` - Network I/O - `Projects/Server/Timer/Timer.Pool.cs` - Pool refill +## Exceptions: Vetted Workers in UOContent + +**A background thread is a last resort.** The forbidden list is about game logic, which is never +threaded. A dedicated worker touching no game state is the sanctioned way off the loop, and +necessarily uses `new Thread`, `ConcurrentQueue`, `Interlocked`, `AutoResetEvent` and +`volatile` **at the thread boundary only**. + +### Prove the need first + +- Measure **on-loop time**, not wall-clock. Frozen world is the cost; player latency is not. +- Off-loading creates no CPU. On 1-2 cores there is no spare core — gate on `ProcessorCount`. +- Count what stays: dispatch, continuation, and the loop slowing while the worker evicts shared L3. +- Record the measurement, or nobody can re-justify the worker later. + +### Game logic stays on the loop — chunk it + +Work needing game state cannot be threaded at any core count. Too slow for one tick? Split across +ticks, bounded by count or elapsed time — never "until done". + +```csharp +Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromMilliseconds(50), () => +{ + var budget = 0; + while (_cursor < _items.Count && budget++ < 100) { Process(_items[_cursor++]); } +}); +``` + +### Vetted workers + +| Worker | Justification | +|---|---| +| `Accounting/Security/PasswordWorker.cs` | 8.9 ms/login on-loop at Argon2; 3.5-8.9 ms measured saving | +| `Engines/Advanced Search/AdvancedSearchGump.cs` | Admin-triggered full-world scan, saves disabled | + +### The six rules + +1. No game state read or written off-thread; dispatch immutable values captured on the loop. +2. Resolve policy (algorithm, salt, era branch) at dispatch — the worker holds none. +3. Park on a kernel wait, never spin. Spinning burns a core on shared hosts. +4. Run only while `WorldState is Running or WritingSave`. **Not** `World.Saving` — that misses + `PendingSave`, where serialization threads are already spinning. +5. Bounded queue, or a bound upstream named in a comment. +6. Everything the worker calls must itself be thread-safe. A singleton is not automatically safe — + `HashAlgorithm.ComputeHash` carries state, `Utility`'s RNG is a shared `System.Random` and game + state. Prefer static one-shot APIs (`SHA256.HashData`, `RandomNumberGenerator.Fill`). + +### Crossing the boundary + +Dispatch captures what the continuation will need to re-validate: + +```csharp +var job = new Job { Target = state, Expected = account.Password, Input = DerivePhrase(...) }; +if (!Worker.TryEnqueue(job)) { /* reject — never fall back to running it inline */ } +``` + +Hand back one of two ways, and no other: + +```csharp +Core.LoopContext.Post(() => Apply(job, result)); // a result for a specific caller +Volatile.Write(ref _snapshot, newTable); // a shared table rebuilt periodically +``` + +The continuation re-validates, because time passed: + +```csharp +if (job.Target?.Running != true) { return; } // gone +if (account.Password != job.Expected) { return; } // changed underneath +``` + +Always post a result, including on failure — a worker that throws silently leaves its caller +waiting forever. Use `ConfigureAwait(false)` on every await inside off-loop work. + ## Anti-Patterns | Pattern | Problem | Solution | diff --git a/dev-docs/claude-skills/modernuo-timers.md b/dev-docs/claude-skills/modernuo-timers.md index 2c5c8168e..f366b3569 100644 --- a/dev-docs/claude-skills/modernuo-timers.md +++ b/dev-docs/claude-skills/modernuo-timers.md @@ -147,18 +147,27 @@ public partial class DecayingItem : Item } ``` -### [DeserializeTimerField] Pattern (for Timer fields) +### [DeserializeTimer] Pattern (for Timer fields) +Required on every serializable `Timer` member. Drifting by default: the next tick is stored +as anchored time, so server downtime does not consume the remaining delay. Use +`wallClock: true` for absolute deadlines (delay is negative if it passed during downtime). +The method is invoked **only when a timer was running at save** — no sentinel to check. + ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeEvaluateTimer), wallClock: true)] private Timer _evaluateTimer; -[DeserializeTimerField(0)] private void DeserializeEvaluateTimer(TimeSpan delay) { _evaluateTimer = Timer.DelayCall(delay, EvaluationInterval, Evaluate); } ``` +Switching an existing timer between drifting and `wallClock` changes the wire format — bump +the class's `[SerializationGenerator]` version and add a `MigrateFrom` (the old content +struct exposes `XxxDelay`, `TimeSpan.MinValue` when no timer ran). + ### Custom Timer Class (When You Need Complex Logic) ```csharp private class DecayTimer : Timer diff --git a/dev-docs/configuration.md b/dev-docs/configuration.md index 9ddc348a7..b69349451 100644 --- a/dev-docs/configuration.md +++ b/dev-docs/configuration.md @@ -98,6 +98,7 @@ Examples from the codebase: accountHandler.enableAutoAccountCreation accountHandler.enablePlayerPasswordCommand accountHandler.maxAccountsPerIP +accountSecurity.encryptionAlgorithm autosave.enabled autosave.saveDelay world.savePath diff --git a/dev-docs/content-patterns.md b/dev-docs/content-patterns.md index 30bb942f3..ec56e1bb3 100644 --- a/dev-docs/content-patterns.md +++ b/dev-docs/content-patterns.md @@ -254,6 +254,35 @@ public override int TreasureMapLevel => 3; // Drops treasure map public override double WeaponAbilityChance => 0.4; // Weapon ability chance ``` +### Creature Speeds (think vs move clocks) + +All "speed" values are **delays in seconds** (smaller = faster). A creature runs two clocks: + +- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision + (combat decisions, target acquisition, spell timing). +- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per + step. Inherits the matching think value until overridden, so a creature configured with + only think speeds behaves as one clock. Any value is legal — steps are scheduled + independently of think ticks, so the two need not divide evenly. + +Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type +lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: + +```csharp +public override SpeedLevel SpeedClass => SpeedLevel.Slow; // bucket in npc-speeds.json +``` + +Code-level overrides for special cases: + +```csharp +SetSpeed(0.5, 2.0); // think clock; ALSO clears move overrides (one-clock legacy semantics) +SetMoveSpeed(0.45, 0.9); // move clock only — call after SetSpeed if both are wanted +ClearMoveSpeed(); // back to inheriting the think clock +``` + +All four are `[props`-tunable per instance (move values: set `0` to re-inherit); per-instance +move overrides serialize. Being badly hurt slows steps, never decisions (RunUO parity). + --- ## New Spell diff --git a/dev-docs/debugging-event-loop.md b/dev-docs/debugging-event-loop.md new file mode 100644 index 000000000..c38a0e6f7 --- /dev/null +++ b/dev-docs/debugging-event-loop.md @@ -0,0 +1,123 @@ +# Debugging Event Loop Performance + +How to diagnose "the server feels slow" — written for both humans and AI assistants. Follow the +funnel in order; most incidents resolve before the last step. Do not start with dotnet-trace. + +## The model + +Every second of the main thread's wall time goes to exactly one of four places: + +1. **Work** — the loop's phases: mobile deltas, item deltas, timer callbacks (`Timer.Slice`), + network processing (`NetState.Slice`), posted tasks (`LoopContext`), world snapshots + (`WorldSnapshot` — the on-loop portion of a save). +2. **Sleep** — idle blocking in `NetState.WaitForCompletion`, bounded by the next timer tick and + `server.eventLoopIdleWaitMs`. +3. **GC pauses** — land inside whichever phase (or sleep) was running. +4. **Stolen** — the host ran something else: hypervisor scheduling, noisy neighbors, CPU credit + throttling. + +A sleep is bounded by the time to the next wheel turn, so **a correctly honoured sleep can never +cost a deadline**. The only way sleeping harms the game is the wait *returning late* — that is +stolen time, and the server measures it directly on every sleep. + +## Step 0 — Read what production already tells you + +No build changes needed. Three signals exist, all actionable: + +| Signal | Meaning | Action | +|---|---|---| +| Startup error: *host cannot honour short waits* | No high-resolution timer and `timeBeginPeriod` failed. Very old or unusual Windows. | Nothing is wrong with the server; it spins and uses a full core. Upgrade the OS or accept the core. | +| Warning: *host returned a Nms idle wait late … for the Nth time running* | The OS did not reschedule the process promptly after a 1–2ms wait, through several escalating backoffs. Shared/burstable vCPU signature. | Move to dedicated CPU, or set `server.eventLoopIdleWaitMs=0` to spin permanently. This is a **host** problem — no amount of server-side change fixes it. | +| Error: *keeps returning idle waits late and sleeping has backed off N times* | The escalation hit its 120s ceiling. The host is not going to recover. | As above, but stop waiting for it to settle. Logged once per degradation, re-armed after a clean minute. | +| Admin gump → Performance → *Event Loop* | `Healthy` / `Sleep suspended (host)` / `Spinning (configured)` / `Spinning - host cannot honor short waits` | Same as above; the last verdict is the startup error's state, not a config choice. | + +The first two backoffs of any episode log at **Debug**, not Warning: a single suspension is +recoverable and not something an operator can act on. Raise the log level if you are chasing a +marginal host and want to see them. Late wakes that coincide with a gen1-or-higher GC are not +counted at all — the GC deliberately collects during idle sleeps, so its pauses land there by +design and are not the host's fault. + +If none of these fired and the shard still feels laggy, the cause is work, GC, or something a +boot-time signal cannot see. Continue. + +## Step 1 — Flip the profiling build + +``` +dotnet build -p:EventLoopProfiling=true +``` + +This compiles in `EventLoopProfiler` (Server) and the `[LoopStats` command (UOContent). Without +the flag every hook call site is removed by the compiler (`[Conditional]`), so there is nothing to +"turn off" in normal builds and no cost to leave the hooks in the code. The profiling build's own +overhead is a handful of timestamp reads per iteration — small enough to run for days while +hunting an intermittent problem. + +**Capture a baseline first.** Run `[LoopStats` while the shard feels *fine* and keep the CSV. The +profiler also keeps ~15 minutes of history in memory, so if the problem is episodic you can wait +for an episode and the good minutes on either side are already recorded. Numbers without a +baseline are how RunUO's profiler became useless — always compare bad minutes to good minutes on +the same box, build, and world. + +## Step 2 — Read the decomposition + +`[LoopStats` prints the last minute and writes the full history CSV (one row per second). Match +the shape against these signatures: + +| Signature | Diagnosis | Next step | +|---|---|---| +| One phase consistently hot (e.g. `TimerSlice` 40%/s) | Deep processing in that subsystem | Step 3 — find the culprit in that phase | +| All phases near zero, `stolen` high, `lateWakes` > 0 | Host is stealing CPU | Host problem; see step 0 actions | +| `gcPauseMs` high, gen2 counts rising | GC pressure — something is allocating heavily | Step 3 on the allocating phase, or dotnet-counters for alloc rate | +| Iterations ≫ sleeps while shard is idle | The loop is not sleeping: a queue never drains or a wake storm | Check `IsIdle` inputs; a stuck signal in the ring is the historical example | +| Sleeps ≈ iterations, each sleep ~0ms | Spurious wake storm | Ring backend issue; count `wakesIssued` vs actual cross-thread posts | +| Everything normal, complaint persists | Not the event loop | Look at the network path, client, or DB/save timing | + +**Wheel lag vs player lag:** `wheelLagMaxMs` is how late timer callbacks fired. Receives are +handled the moment they arrive (they wake the loop), so player-felt lag with a clean wheel points +away from the loop entirely. + +## Step 3 — Find the culprit inside a hot phase + +Add a temporary culprit hook rather than reaching for a tracer. The pattern: same +`[Conditional("EVENT_LOOP_PROFILING")]` attribute, own file or the profiler file, record only the +worst offender per second (identity + duration), never a per-event log. Examples: + +- `TimerSlice` hot → time each timer callback, keep the max and its `timer.ToString()`. +- `NetworkSlice` hot → time packet handlers by packet id, keep the max. +- GC pressure → `dotnet-counters monitor --counters System.Runtime` for alloc rate first; it is + cheap and often names the culprit generation without a trace. + +Keep the hook after the hunt if it earns its cost in the profiling build; delete it otherwise. + +## Step 4 — dotnet-trace, last and targeted + +Only when a hot phase resists the culprit hook. Know the costs: EventPipe visibly slows the +process (worst exactly when things are already bad) and adds artifacts to the trace — on small +vCPU hosts the tracer's own threads appear as hotspots and Rider/PerfView hotspot views can +mislead. Mitigate by being narrow: + +- Trace the specific minutes the decomposition flagged, not "a while". +- `dotnet-trace collect --profile cpu-sampling --duration 00:00:30` is usually enough. +- Compare against a trace of a good minute (same rule as step 1: no baseline, no conclusions). + +## The RAM / GC misconception (read before declaring a leak) + +ModernUO allocates very little, and the GC collects opportunistically — mostly during idle sleeps +and world saves. Under a spinning loop (`eventLoopIdleWaitMs=0`, or the pre-2026 default) the GC +may find **no** natural pause point: memory climbs to a large fraction of physical RAM, a forced +collection eventually drops part of it, and fragmentation keeps the baseline permanently above +where it started. Task manager shows alarming numbers; the in-game numbers do not. **Performance +is unaffected — this is lazy collection working as designed, not a leak.** Idle sleeping largely +removes the effect because every sleep is a natural GC opportunity. Before investigating "a leak": +check `gen0/1/2` and `gcPauseMs` in the decomposition, and compare working set *after a world +save*, which forces the collection the spin loop never allowed. + +## Rules of thumb + +- Never trade always-on profiling for the numbers. Production carries one timestamp per sleep and + nothing else; everything heavier lives behind the build flag or on the `measure/event-loop` + branch (full harness, A/B scripts, vendored ring experiments). +- One decomposition chart beats a thousand log lines. Resist adding warnings the reader cannot + act on; the three production signals are deliberate. +- When filing or reporting: attach the baseline CSV and the episode CSV. Relative statements + ("TimerSlice went from 4% to 61% during the episode") are the useful form. diff --git a/dev-docs/ip-bans-and-allowlists.md b/dev-docs/ip-bans-and-allowlists.md new file mode 100644 index 000000000..40320efe5 --- /dev/null +++ b/dev-docs/ip-bans-and-allowlists.md @@ -0,0 +1,236 @@ +# IP Bans, Blocklists and Allowlists + +How a shard decides to refuse a connection, how it contributes bans to an external bouncer, and how an +operator exempts someone who was caught by mistake. + +If you are here because **a player cannot connect**, skip to [Unblocking a player](#unblocking-a-player). + +## The shape of it + +Two independent questions, deliberately separated: + +| Question | Answered by | Effect | +|---|---|---| +| Refuse this connection? | `IConnectionFilter` implementations, at accept | The socket is dropped | +| Tell the outside world about it? | `BanChannel` → `IBanReporter` implementations | CrowdSec, and from there an OS bouncer | + +`BanChannel` **never enforces** and filters **never report on each other's behalf**. Enforcement that +outlives the process belongs to the OS bouncer; the shard only contributes. + +### Refusing a connection + +Filters are consulted in registration order, first denial wins: + +| Filter | Source | Scope | +|---|---|---| +| `firewall` | `Configuration/firewall.json`, mutable in-game | Admin-curated, permanent | +| `blocklist` | `Configuration/ip-blocklist.txt` (millions of entries, opt-in) | Reputation feeds | +| `auto-denylist` | In-memory, 15 min | What this shard just caught misbehaving | + +### Contributing a ban + +`BanChannel.Report(address, ttl, reason)` → `BanExemptions.IsExempt` → if not exempt, fan out to every +reporter (`crowdsec`, `auto-denylist`). + +### Allowlists + +Two, with different authority: + +| List | Source | Revocable? | Covers | +|---|---|---|---| +| `ManualAllowlist` | every `ip-allowlist*.txt` (opt-in) | No — an operator said so | Blocking **and** escalation | +| `LoginAllowlist` | Earned by authenticating, 90-day TTL | Yes — 10 strikes/hour | Blocking **and** escalation | + +Both are consulted **only after the blocklist has already matched**, so a normal accept — the one an +attacker is trying to flood — pays nothing for them. The accept gate itself is deliberately allowlist-free: +a whitelist there could only turn a deny into an allow at the cost of a lookup on every accept, the +attacker's included. + +## Unblocking a player + +### 1. Find out what is actually blocking them + +```bash +# In the reputation blocklist? +grep -x "203.0.113.42" Distribution/Configuration/ip-blocklist.txt + +# A live external decision? (this is what survives a restart) +cscli decisions list --ip 203.0.113.42 + +# Admin-curated? +grep 203.0.113.42 Distribution/Configuration/firewall.json +``` + +If none of those match, they may be inside a **CIDR** in the blocklist, or held by the in-memory +`auto-denylist` — that one is not queryable and expires on its own within 15 minutes. + +### 2. Add them to the allowlist + +Set `"enabled": true` in `Configuration/ip-allowlist.json` first — it is off by default, so a shard that +has never written a carve-out does not poll for one. The shard logs a warning at startup if allowlist files +are present while the flag is off. + +Then one entry per line in `Distribution/Configuration/ip-allowlist.txt` — a bare address or a CIDR. This +file is yours; the generator creates it once and never rewrites it. + +``` +203.0.113.42 # shard owner, listed via a shared upstream address +198.51.100.0/24 # a whole range if the ISP rotates within it +``` + +With the flag on, the shard reloads within `reloadInterval` (60s default). **No restart, and no need to +re-run the generator.** From that point the address is neither blocked nor contributed. With the flag off +the generator still subtracts the file at generation time, so the address stops being *blocked* — but a +behavioural detection can still contribute it, which is the case the flag exists to cover. + +### 3. Clear any ban that already exists + +A config change cannot retract a ban that has already left the building: + +```bash +cscli decisions delete -i 203.0.113.42 +``` + +### 4. If it keeps coming back + +The shard is no longer contributing them, so a recurring ban is coming from CrowdSec's own sources (the +community blocklist, another watcher). Allowlist it there too: + +```bash +cscli allowlists create shard-staff -d "Known-good player addresses" +cscli allowlists add shard-staff 203.0.113.42 +``` + +### What will NOT work + +- **Deleting the CrowdSec decision alone.** If the address is still in `ip-blocklist.txt` and not + allowlisted, the next connection re-reports it within `promoteSuppression` (60s). +- **Editing `ip-blocklist.txt` by hand.** The next generator run rewrites the whole file. +- **`cscli allowlists` alone.** That is the enforcement layer. The shard's own accept gate sits upstream of + it and will still refuse the connection. + +## Why entries appear that should not + +Reputation feeds list shared consumer address space constantly. On CGNAT one public address fronts many +subscribers **at the same time**, so a single abusive customer gets the address listed and everyone else +behind it is blocked with them — and where leases rotate, a listing says little about whoever holds the +address now. This is near-universal on mobile carriers, satellite (Starlink) and WISPs, and common on +fixed-line broadband outside North America. + +A **carve-out** exempts a whole network. **None ship with ModernUO** — which providers to exempt depends on +where your players actually are, and a carve-out names a real network, so you build the ones you need: + +```powershell +# Exempt a CGNAT provider whose players keep getting listed +.\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 + +# Later: bring every carve-out up to date with what those networks currently announce +.\Export-IpBlocklist.ps1 -RefreshCarveouts +``` + +That writes `ip-allowlist-starlink.txt` beside the blocklist, and every `ip-allowlist*.txt` there is +subtracted by the generator with no config edit. For the shard to read them too — which is what also stops +a carve-out address being *contributed* by a behavioural detection — set `enabled` in `ip-allowlist.json`; +it is off by default so no shard polls for files it never wrote. Starlink costs about 0.1% of the list. +Blank a file (keep the file) to reputation-block that network again; delete it to drop the carve-out. + +Carve-out files carry an `asn=` marker in their header, which is how `-RefreshCarveouts` finds them. A +hand-written allowlist has no marker and is never rewritten. + +**Do you need one?** If players report being blocked and they are on satellite, mobile, or an ISP short on +IPv4, probably yes. Find the ASN by looking up an address the network hands out on any public BGP lookup. + +Prefixes come from **announcements, not ownership records**. Registry data disagrees with what is actually +routed and silently caps result sets: ARIN whois returns at most 256 rows and gives per-customer /24s, and +`206.83.96.0/19` reads as APNIC in RDAP even though `206.83.96/21` is announced by Starlink. + +## Behavioural detection + +Verdicts the shard reaches by watching a connection, rather than by consulting a list: + +| Reason | Trigger | +|---|---| +| `rate-limit` | Too many connection attempts in the limiter's window | +| `silent-connect` | Reaped after `ConnectingSocketIdleLimit` (5s) having sent **zero bytes** | +| `invalid-seed` | Opened with a zero seed, which no real client sends | +| `foreign-protocol` | Positively identified as HTTP, TLS or SSH | + +`BanReasons.IsBehavioral` gates two things: only these may be exempted, and only these enter the +`auto-denylist`. It is an **opt-in list**, not "everything except `manual`" — a reason added later escalates +normally rather than silently inheriting an exemption. `manual` is never exempt and never auto-denied. + +Escalation is **immediate**, on the first detection: a 15-minute local hold plus a `badConnectDuration` +(4h) contribution. There is no N-connection threshold; the strike counter governs only revoking a +`LoginAllowlist` entry. + +The local hold runs 15 minutes from the **first** detection and is never extended by later ones, so an +address that keeps trying is released on schedule rather than held indefinitely. It does not get a free +run: the rate limiter sits *ahead* of the connection filters, so a flooder is re-reported and re-held on +its next attempt. Not refreshing is what keeps the holds in expiry order, which is what makes retiring +lapsed ones cost the number expiring rather than the number held. + +### What is deliberately NOT detected + +**Do not add rules based on arrival framing.** TCP has no message boundaries, so the network, the OS or a +middlebox can split the opening bytes anywhere regardless of what the client sent. A rule of the form +"these bytes must arrive together" is broken by construction and drops real players on poor links. This has +been tried and reverted before. + +**Do not treat unreadable payloads as hostile.** A legitimate client with encryption enabled when the shard +expects none sends a structurally perfect connection whose payload is noise — `LoginEncryption.ClientDecrypt` +is a byte-for-byte stream XOR, so it preserves length exactly while destroying content. This is why +detection asks "is this positively some *other* protocol?" rather than "is this a good UO client?": however +misconfigured a UO client is, it never sends `GET / HTTP/1.1`. + +**Timeouts are keyed on bytes-received, not elapsed time.** A connection that sent *something* and ran out +of time is far more likely a slow link than an attack. Banning those produces the worst failure mode +available: the player retries, trips the rate limiter, and compounds a bad connection into hours of being +firewalled off. Shortening the 5s handshake window has been tried and broke real players. + +## Known limits + +- **An allowlist cannot bootstrap.** A `LoginAllowlist` entry is only earned by getting in, so it can never + repair an existing false positive, and it is weakest on rotating CGNAT — a player whose lease moved is a + stranger again. `ManualAllowlist` is the fix for that, which is why it is manual — and opt-in, via + `ip-allowlist.json`. +- **A never-logged-in player on a shared address can still be caught**, for up to `badConnectDuration`, if a + co-tenant misbehaves. Accepted: it is 4h and self-healing. The cheapest lever is `badConnectDuration`. +- **`MaxConnections` (4096) is a hard ceiling.** The accept gate runs *after* the kernel completed the TCP + handshake, so a blocklist match saves the socket setup and the `NetState` slot but never the connection + itself. Only an upstream L4 proxy or edge scrubbing moves that cost off the shard. +- **The `auto-denylist` stops tracking at `maxEntries`.** Past it a detection still disconnects the + connection, but the address is not held, so it pays full detection cost on every reconnect instead of a + cheap accept-gate deny. The default is sized for the 50k–250k distinct-source floods seen in practice; a + flood past it wants upstream scrubbing rather than a larger cap, which only buys a longer on-loop scan. + +## Configuration + +| File | Controls | +|---|---| +| `bans.json` | `reportRateLimitTrips`, `autoBanDuration`, `reportBadConnects`, `badConnectDuration` | +| `blocklist.json` | `enabled` (default `false`), `file`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` | +| `ip-allowlist.json` | `enabled` (default `false`), `files` (wildcards allowed), `reloadInterval` | +| `login-allowlist.json` | `enabled`, `file`, `ttl`, `flushInterval`, `escalateAfterStrikes`, `strikeWindow` | +| `auto-denylist.json` | `enabled`, `duration`, `maxEntries` (default `324,449` — sized for the floods seen in practice; see the remark on the setting before raising it) | +| `crowdsec.json` | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` | +| `firewall.json` | Admin-curated entries | + +A shard fronted by an upstream proxy can disable all of it and register nothing. + +## Key files + +| File | Role | +|---|---| +| `Projects/Server/Network/IConnectionFilter.cs` | Accept-path gate contract | +| `Projects/Server/Network/ConnectionFilters.cs` | Filter registry + lifecycle | +| `Projects/Server/Network/ForeignProtocol.cs` | Positive identification of non-UO traffic | +| `Projects/Server/Network/Bans/BanChannel.cs` | Contribution fan-out + `IsExempt` seam | +| `Projects/Server/Network/Bans/BanReasons.cs` | Reason slugs + the behavioural opt-in set | +| `Projects/UOContent/Network/BanExemptions.cs` | Combines both allowlists into one answer | +| `Projects/UOContent/Network/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter | +| `Projects/UOContent/Network/Blocklist/ManualAllowlist.cs` | Operator carve-outs, read from the allowlist files | +| `Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs` | Allowlist earned by authenticating | +| `Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs` | Short-lived local hold | +| `Projects/UOContent/Network/CrowdSec/CrowdSecReporter.cs` | LAPI contribution sink | +| `Projects/UOContent/Network/Firewall/Firewall.cs` | Admin-curated firewall set | +| `tools/Export-IpBlocklist.ps1` | Blocklist generator + allowlist subtraction | diff --git a/dev-docs/networking-packets.md b/dev-docs/networking-packets.md index 43d4aec15..639963cfd 100644 --- a/dev-docs/networking-packets.md +++ b/dev-docs/networking-packets.md @@ -473,6 +473,70 @@ ns.SendMovementRej(int sequence, Mobile m); 6. **Big-endian by default** -- only use `WriteLE`/`ReadLE` when the protocol requires it 7. **Function pointers** (`&Handler`) for incoming packet registration (no delegate allocation) +## Connection Filtering (Accept Path) + +Every inbound socket is checked before it becomes a `NetState`. The check runs on the game loop once +per accepted connection -- this is the path that has to survive a DDoS -- so it must be allocation-free +and non-blocking. + +Gates plug in through `IConnectionFilter`, registered with `ConnectionFilters.Register()` during the +Configure sweep: + +```csharp +public sealed class MyFilter : IConnectionFilter +{ + public string Name => "my-filter"; + public void Configure() { /* read config, no I/O */ } + public void Start(CancellationToken token) { /* background hydration */ } + public void Stop() { } + public bool ShouldDeny(IPAddress address) => /* allocation-free membership test */; +} + +// In a static Configure() so the sweep finds it: +ConnectionFilters.Register(new MyFilter()); +``` + +Rules: + +- `ShouldDeny` must be **allocation-free**, O(log n) at worst, no I/O, no blocking. Anything expensive + (parsing, reloading, contributing to an external service) belongs off the loop or behind a bounded, + non-blocking enqueue. +- Side effects a hit implies (reporting to `BanChannel`, promoting to an OS firewall, suppressing + duplicate reports) are the **filter's** business, not the accept path's. +- Filters are consulted in registration order and the first denial short-circuits, so register the + cheapest and most selective first. Order affects only how quickly a denial is reached, never whether + one happens. +- A filter that throws is **unregistered** and the connection fails open. A filter that faults once + faults for every connection, so leaving it registered would mean an exception per accept. + +Core owns the question; **every implementation lives in UOContent**. The three that ship are `firewall` +(admin-curated, mutable at runtime, persisted to `Configuration/firewall.json`), `blocklist` (file-sourced, +millions of entries, demand-pages hits to CrowdSec, **opt-in**) and `auto-denylist` (in-memory, +short-lived, fed by the shard's own behavioural detections). A shard that fronts its server with an +upstream proxy or edge scrubbing can drop all of them and register nothing. + +The allowlists, ban contribution, behavioural detection and the operator process for exempting a +false-positive address are covered separately in +[`dev-docs/ip-bans-and-allowlists.md`](ip-bans-and-allowlists.md). + +Do **not** route this kind of check through `EventSink.InvokeSocketConnect` -- that fires later and +allocates a `SocketConnectEventArgs` per connection, which is exactly what the accept path avoids for +rejected traffic. + +### IP Address Normalization (`IPAddressUtility`) + +Addresses are normalized to `UInt128` in **IPv6 form** so a single comparison/index works for both +families. An IPv4 address becomes its v4-mapped-v6 value (`::ffff:a.b.c.d`), which is why round-tripping +matters: `IPv4 -> UInt128 -> IPAddress` can come back as `InterNetworkV6` with `IsIPv4MappedToIPv6` +set, even though it is "really" a v4 address. Code that switches on `AddressFamily` alone will mis-handle +those, so the helpers check both. + +> **Known wart / follow-up:** `ToUInt128` guards with `AddressFamily == InterNetwork && !IsIPv4MappedToIPv6`. +> Per BCL semantics `IsIPv4MappedToIPv6` is only ever true for `InterNetworkV6`, so the second clause +> reads as redundant -- it is really defending the round-trip described above. The normalization would be +> clearer as an explicit "to canonical v6 bits" step that never needs the family check at all. Deliberately +> left as-is; to be revisited in a follow-up PR rather than churned mid-feature. + ## Key File References | File | Description | @@ -492,3 +556,12 @@ ns.SendMovementRej(int sequence, Mobile m); | `Projects/Server/Network/Packets/OutgoingAccountPackets.cs` | Account packets | | `Projects/Server/Network/Packets/OutgoingContainerPackets.cs` | Container packets | | `Projects/Server/Network/PacketHandler.cs` | PacketHandler class | +| `Projects/Server/Network/IConnectionFilter.cs` | Accept-path gate contract | +| `Projects/Server/Network/ConnectionFilters.cs` | Filter registry + lifecycle | +| `Projects/UOContent/Network/Firewall/Firewall.cs` | Admin-curated firewall set | +| `Projects/Server/Utilities/IPAddressUtility.cs` | IPAddress <-> UInt128 normalization, CIDR parsing | +| `Projects/UOContent/Network/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter | +| `Projects/UOContent/Network/LoginAllowlist/LoginAllowlist.cs` | Allowlist earned by a recent successful login | +| `Projects/UOContent/Network/AutoDenylist/AutoDenylist.cs` | Short-lived local hold on behavioural detections | +| `Projects/Server/Network/Bans/BanReasons.cs` | Ban reason slugs + the behavioural opt-in set | +| `Projects/Server/Network/ForeignProtocol.cs` | Positive identification of non-UO traffic (HTTP/TLS/SSH) | diff --git a/dev-docs/pathfinding.md b/dev-docs/pathfinding.md index dfe3d8e09..861a41d75 100644 --- a/dev-docs/pathfinding.md +++ b/dev-docs/pathfinding.md @@ -139,13 +139,17 @@ several-minutes cost. Wiring: after assemblies load (so content can register prompts) but **before Serilog starts**, so the console prompt is not interleaved with the async console sink. Any class can participate by defining `public static void ConfigurePrompts()` and self-gating on first-boot state. -- The bake runs in the later `Invoke("Initialize")` phase (after the tile matrix + world load, - which the bake walks). +- Both the reader open and the bake run in `Invoke("Initialize")`, after the tile matrix and world + load. Neither belongs in `Configure`, which runs *before* both: the fingerprint hashes the map + files, so opening a `.swb` there would force the lazy `Map.Tiles` property and build every + `TileMatrix` ahead of `TileMatrixLoader` — possibly before `TileMatrix.Configure()` settles + `Pre6000ClientSupport`, since both sit at the default call priority and the phase sort is + unstable. `PathCacheCommands.Configure` is limited to settings and command registration. - Staleness is decided by the `.swb` fingerprint, which `StepCacheFile.OpenForLazy` validates at open time (hash of `tiledata.mul` + the per-map `.mul`/`.uop` files — never the in-memory - `TileData` tables, which the server patches at runtime). `Configure` opens a reader for every - up-to-date file; the bake in `Initialize` then skips any map where `StepCache.HasLazyReader` is - already true, so the fingerprint is computed once per boot, not twice. + `TileData` tables, which the server patches at runtime). `Initialize` opens a reader for every + up-to-date file, then skips any map where `StepCache.HasLazyReader` is already true, so the + fingerprint is computed once per boot. Each newly baked map reopens only itself. ## Configuration levers @@ -154,7 +158,7 @@ several-minutes cost. Wiring: | `pathfinding.enable` | `PathFollower.Configure` | `true` | Master switch for `PathFollower` pathfinding. Off → greedy/auto-turn only, no A* at all. | | `bitmap_pathfinding_cache` feature flag (`ContentFeatureFlags.BitmapPathfindingCache`, `Server.Systems.FeatureFlags`) | `FeatureFlagManager` | `true` | Off → `BitmapAStar` routes straight to the slow path with **no cache probe and no warming memory**. ≈ old FastAStar at ~1×. | | `pathfinding.maxResidentChunks` | `PathCacheCommands.Configure` | 8192 (~40 MB) | LRU cap on resident chunks = the warming-memory ceiling. Lower it (e.g. 512–1024 ≈ 2.5–5 MB) on small shards. | -| `pathfinding.maxSearchNodes` | `PathCacheCommands.Configure` → `BitmapAStarAlgorithm.MaxSearchNodes` | 1000 | A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot. | +| `pathfinding.maxSearchNodes` | `BitmapAStarAlgorithm.Configure` → `BitmapAStarAlgorithm.MaxSearchNodes` | 1000 | A* per-Find node-expansion budget. See limits above; ~1000 is the sweet spot. | | `pathfinding.prebakeMaps` | `PathCacheCommands` (first-boot prompt + `Initialize`) | `false` | When set, bakes any missing/stale `.swb` for the selected maps at startup (fingerprint-gated, so a fresh cache is a no-op). Set interactively by the first-boot prompt. | | `PathFollower` `RepathDelay` | `PathFollower.cs` (const) | 2 s | Throttle: a moving goal re-`Find`s at most ~once per 2 s; a stationary reachable goal is pathed once and reused until arrival. Not a setting (compile-time). | diff --git a/dev-docs/platform-prerequisites.md b/dev-docs/platform-prerequisites.md new file mode 100644 index 000000000..3e76d3b47 --- /dev/null +++ b/dev-docs/platform-prerequisites.md @@ -0,0 +1,159 @@ +# Platform Prerequisites + +OS-level dependencies ModernUO needs at runtime, why each one is required, and what breaks without +it. This page is about software packages, not hardware sizing. + +Run `./build-tool --check-prereqs` from the repository root to check the current machine. It prints +the exact install command for the detected distribution. + +## What is required + +| Dependency | Platform | Why | +|---|---|---| +| .NET 10 Runtime | all | — | +| ICU (`libicuuc`, `libicui18n`) | Linux, macOS | The runtime refuses to start without it; see below | +| tzdata | Linux | Time zone lookups; see below | +| `libdeflate` | all | `LibDeflate.Bindings` | +| `libargon2` | all | `Argon2.Bindings` (password hashing) | +| VC++ Redistributable v14 | Windows | Native bindings | + +Not required, despite appearances: + +- **zstd** — `ZstdNet` bundles `libzstd` for every RID. +- **liburing** — `IORingGroup` issues `io_uring` syscalls directly. It imports only `libc`, + `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll` and `ws2_32.dll`. +- **`-dev` / `-devel` packages** — see "Runtime packages only" below. + +## Install + +```sh +# Debian / Ubuntu (ICU has no stable package alias, so match it by pattern) +sudo apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata + +# Fedora / RHEL +sudo dnf install -y libdeflate libargon2 libicu tzdata + +# Alpine +apk add --no-cache libdeflate argon2-libs icu-libs tzdata + +# macOS +brew install icu4c libdeflate argon2 +``` + +CentOS additionally needs EPEL and CRB: + +```sh +sudo dnf install -y epel-release epel-next-release && sudo dnf config-manager --set-enabled crb +``` + +## Runtime packages only + +Only the runtime packages are needed. The `-dev`/`-devel` packages are **not** required. + +They used to be, because .NET's `DllImport` probing looks for the unversioned `libfoo.so`, and on +Linux that bare symlink ships only in the development package. The runtime package ships the +versioned SONAME (`libdeflate.so.0`, `libargon2.so.1`). The binding packages now probe the versioned +names as well, so the runtime package is sufficient. + +Anything still documenting `libicu-dev` or `libdeflate-dev` as a requirement is out of date. + +## ICU + +`Directory.Build.props` sets `InvariantGlobalization=false`, so ICU is mandatory. Without it the +runtime does **not** throw — it `FailFast`s: + +``` +Couldn't find a valid ICU package installed on the system. Please install libicu (or icu-libs) +using your package manager and try again. +``` + +That is `SIGABRT` (exit 134) and it cannot be caught. Note the process **starts cleanly and aborts +later**, at whatever line first touches a culture, so the crash rarely points at the cause. + +### Why invariant mode is not an option + +`InvariantGlobalization=true` would remove the ICU dependency, but it changes behaviour in ways that +corrupt data silently. Measured on .NET 10 with the repository's settings: + +| Behaviour | With ICU | Invariant mode | +|---|---|---| +| `new CultureInfo("de-DE")` | real culture | succeeds, returns invariant data | +| de-DE decimal separator | `,` | `.` | +| `1234.5` as de-DE | `1.234,5` | `1,234.5` | +| `string.Compare("a", "B", InvariantCulture)` | `-1` (linguistic) | `31` (ordinal) | +| sort `[b, A, a, B]` | `a, A, b, B` | `A, B, a, b` | +| `FindSystemTimeZoneById("Eastern Standard Time")` on Linux | resolves | `TimeZoneNotFoundException` | +| UTF-8 round-trip of non-ASCII | unaffected | unaffected | + +The dangerous row is the first. Because `Directory.Build.props` also sets +`PredefinedCulturesOnly=false`, constructing a culture in invariant mode **succeeds** instead of +throwing `CultureNotFoundException`, and hands back an object populated with invariant data. Number +parsing and formatting then produce wrong values with no error, and culture-sensitive sort order +silently becomes ordinal. + +Encoding is not affected — UTF-8 round-trips correctly in both modes. + +### Version floor + +The runtime accepts `libicuuc.so.60` and above (`MinICUVersion` in `pal_icushim.c`). The prerequisite +checker enforces the same floor, so a host carrying only an older ICU is reported missing rather than +passing and then aborting at startup. RHEL/CentOS 7 ships ICU 50 and is affected. + +ICU tracks its own release train, so the SONAME digit varies widely by distribution — `.so.74` on +Ubuntu 24.04, `.so.76` on Alpine, `.so.77` on Fedora, `.so.78` on openSUSE. There is no stable +package alias on Debian and Ubuntu, which is why the checker resolves the name via `apt-cache` +instead of hardcoding one. + +Only `libicuuc` and `libicui18n` are used; those are the two names +`libSystem.Globalization.Native.so` loads. `libicudata` arrives as a dependency of `libicuuc`, and +`libicuio`/`libicutu`/`libicutest` are never referenced. Every distribution ships all of them in a +single package, so installing ICU at all satisfies both. + +## tzdata + +The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads +`/usr/share/zoneinfo` on Linux. This is separate from ICU: it is data, not a library, so no loader +probe finds it, and slim container images routinely omit it. + +Without tzdata every lookup except `UTC` throws: + +``` +TimeZoneNotFoundException: The time zone ID 'America/New_York' was not found on the local computer. +``` + +`TimeZoneInfo.GetSystemTimeZones()` returns 1 entry instead of ~419, and `TimeZoneInfo.Local` falls +back to UTC. + +### There is no per-zone subset + +Distributions do not package individual zones — it is one `tzdata` package, about 2 MB installed for +the full set. Subsetting is not worth pursuing. + +The one split that does exist is **`tzdata-legacy`** on Debian 12 and Ubuntu 24.04, which carries the +deprecated aliases. With plain `tzdata` alone: + +| Zone ID | `tzdata` | `tzdata-legacy` | +|---|---|---| +| `America/New_York` | present | — | +| `Europe/Kyiv` | present | — | +| `EST5EDT` | present | — | +| `US/Eastern` | **missing** | present | +| `Asia/Calcutta` | **missing** | present | + +So a shard configured with a legacy alias such as `US/Eastern` throws on a current Debian or Ubuntu +even though tzdata is installed. Either install `tzdata-legacy` or switch the configured value to the +canonical ID (`America/New_York`, `Asia/Kolkata`). + +`TZDIR` is honoured if the data lives somewhere non-standard. + +## How the check works + +`--check-prereqs` asks the loader directly — `NativeLibrary.TryLoad` on the unversioned name, then +`libfoo.so.N` descending through the accepted range. + +It deliberately does not consult a package database or `ldconfig -p`. Both answer a different +question than "will `dlopen` succeed": + +- Package queries need a hardcoded name, which does not exist for ICU. +- `ldconfig`'s cache can be stale, omits `LD_LIBRARY_PATH`, and carries no version information to + enforce the ICU floor against. On musl it exits successfully while producing nothing usable. diff --git a/dev-docs/property-lists.md b/dev-docs/property-lists.md index 98e26efc1..929429bcd 100644 --- a/dev-docs/property-lists.md +++ b/dev-docs/property-lists.md @@ -301,6 +301,66 @@ public void UseCharge() } ``` +### Never Invalidate From Inside `GetProperties` (CRITICAL) + +`InvalidateProperties()` rebuilds the list **in place** — `Reset()`, then `GetProperties()` again on +the same instance. Calling it from a property getter that the build itself reaches is therefore +re-entrant, and `Reset()` does two destructive things to the build in flight: + +1. It returns the pooled interpolation scratch buffer. The compiler rents that buffer in the + interpolated-string handler's constructor and returns it in the closing `Add`, so **every hole is + evaluated while the buffer is live**. Pulling it out mid-append makes the next `Append*` span a + null array — `ArgumentNullException: Value cannot be null. (Parameter 'array')` thrown out of + `GetProperties`, from a line that looks unrelated to the getter that caused it. +2. It rewinds the packet cursor, so properties already written are overwritten by the nested pass. + +This is always a defect in the property getter, so the engine refuses rather than trying to recover: +a nested call logs an error with a stack trace, throws in `DEBUG`, and in `RELEASE` returns without +touching the list — leaving a possibly stale tooltip, but never a crash, a corrupted packet, or a +leaked pool buffer. Retrying the build would only hide the bug. + +```csharp +// BAD -- a getter with a side effect. Reading it from GetProperties re-enters the build. +public RankDefinition Rank +{ + get + { + if (_invalidateRank) + { + _rank = Recompute(); + _invalidateRank = false; + Invalidate(); // -> InvalidateProperties() -> Reset() on the list being built + } + + return _rank; + } +} + +// GOOD -- getters stay side-effect free; invalidate where the value actually changes. +public int RankIndex +{ + get => _rankIndex; + set + { + if (_rankIndex != value) + { + _rankIndex = value; + _invalidateRank = true; + Invalidate(); + } + } +} +``` + +Lazy recomputation inside a getter is fine — it is the *notification* that must not happen there. If +something genuinely must invalidate in response to a read, defer it off the build: + +```csharp +Timer.DelayCall(InvalidateProperties); +``` + +**Check when writing a `GetProperties` override**: every property it reads must be a pure read. + ## ObjectPropertyList Internals Defined in `Projects/Server/PropertyList/ObjectPropertyList.cs`: diff --git a/dev-docs/runuo-migration-docs/01-foundation-changes.md b/dev-docs/runuo-migration-docs/01-foundation-changes.md index 0af53c6d7..668701bea 100644 --- a/dev-docs/runuo-migration-docs/01-foundation-changes.md +++ b/dev-docs/runuo-migration-docs/01-foundation-changes.md @@ -282,6 +282,65 @@ public MyItem(Serial serial) : base(serial) { } // ModernUO — DELETE THIS CONSTRUCTOR. The source generator creates it. ``` +## 15. Static `Parse(string)` → `IParsable` / `ISpanParsable` + +RunUO predates `IParsable`/`ISpanParsable` (C# 11 / .NET 7 static-abstract interface +members), so RunUO types that convert from a string expose a bare `public static T Parse(string value)`. +**ModernUO expects any such type to implement `IParsable` (string) and, where practical, +`ISpanParsable` (span; it extends `IParsable`, so implement span and you get both).** + +This matters because the engine's string→value converter, `Server.Types.TryParse` — used by `[set`, +`[props`, spawner property assignment, the conditional-command compiler (`[where`), and Advanced +Search — binds to the `Parse(string, IFormatProvider)` signature. A type with **only** a legacy +`Parse(string)` is discovered by `Types` through a reflection fallback, but that fallback is a safety +net, not the intended path: a bare `Parse(string)` is easy to miss, doesn't participate in the +span-based fast paths, and (if it returns `null` instead of throwing) makes `[set` silently assign +`null` on bad input. Convert it. + +The `Parse` overloads throw `FormatException` on failure; `TryParse` returns `false`. Delegate the +string overloads to a span core (see `Race`, `Poison`, `Point3D` for the established pattern): + +```csharp +// RunUO +public abstract class Faction : IComparable +{ + public static Faction Parse(string name) // returns null on no-match — wrong contract, not IParsable + { + // ... linear search by name ... + return null; + } +} + +// ModernUO +public abstract class Faction : IComparable, ISpanParsable +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s) => Parse(s, null); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Faction Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryParse(string s, IFormatProvider provider, out Faction result) => + TryParse(s.AsSpan(), provider, out result); + + public static Faction Parse(ReadOnlySpan s, IFormatProvider provider) => + TryParse(s, provider, out var result) + ? result + : throw new FormatException($"The input string '{s}' was not in a correct format."); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Faction result) + { + // ... linear search by name using s.InsensitiveEquals(...) ... + result = null; + return false; + } +} +``` + +To find un-migrated types: search for `public static [A-Za-z0-9_<>]+ Parse\(string ` and check whether +the declaring type lists `IParsable`/`ISpanParsable`. + ## Quick Checklist When migrating any RunUO script, apply these changes in order: @@ -300,6 +359,7 @@ When migrating any RunUO script, apply these changes in order: 12. [ ] Modernize property syntax 13. [ ] Remove `Serial` constructor (handled by serialization generator) 14. [ ] Update usings +15. [ ] Convert bare static `Parse(string)` to `IParsable`/`ISpanParsable` ## See Also diff --git a/dev-docs/runuo-migration-docs/02-serialization.md b/dev-docs/runuo-migration-docs/02-serialization.md index 1470792af..666ebfdb4 100644 --- a/dev-docs/runuo-migration-docs/02-serialization.md +++ b/dev-docs/runuo-migration-docs/02-serialization.md @@ -457,16 +457,24 @@ set ``` Without this, changes won't be saved. +Most RunUO custom setters only clamp the value or run side effects after assignment. Those +convert to a plain `[SerializableField]` with the `allowFieldChange`/`fieldChanged` hooks, +which handle the equality check and `MarkDirty()` for you -- reserve `[SerializableProperty]` +for custom getters (see `dev-docs/serialization.md`). + ### 3. Field Ordering The `[SerializableField(N)]` index determines serialization order. Choose a logical order and don't change it after the first save — or increment the version. ### 4. Conditional Serialization -Use `[SerializableFieldSaveFlag]` and `[SerializableFieldDefault]` to skip default values: +Use `[SaveFlag]` on the serializable field to skip default values (the second method is +optional -- omit it and the field keeps its default at load): ```csharp -[SerializableFieldSaveFlag(0)] +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] +private int _maxItems; + private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` @@ -479,12 +487,15 @@ private List _followers; ``` ### 6. DateTime Fields -Use `[DeltaDateTime]` to survive server restarts: +Use `[AnchoredDateTime]` to survive server restarts -- the value is shifted by downtime at +load, so the remaining time is preserved and idle saves stay byte-stable: ```csharp -[DeltaDateTime] +[AnchoredDateTime] [SerializableField(0)] private DateTime _expireTime; ``` +(`[DeltaDateTime]` is the legacy equivalent; it rewrites bytes on every save. Converting an +existing field between the two changes the wire format and requires a version bump.) ### 7. Keeping Manual Serialization (Rare) Some edge cases still need manual serialization. If a type has complex conditional logic that can't be expressed with attributes, you can implement `ISerializable` manually. But this is rare — try attributes first. diff --git a/dev-docs/runuo-migration-docs/03-timers.md b/dev-docs/runuo-migration-docs/03-timers.md index 29f3d1282..13596a079 100644 --- a/dev-docs/runuo-migration-docs/03-timers.md +++ b/dev-docs/runuo-migration-docs/03-timers.md @@ -305,7 +305,7 @@ In RunUO, timers are commonly started in `Deserialize()`. In ModernUO, use `[Aft `_token.Cancel()` can be called on a default token, a stopped token, or an already-cancelled token. No null checks needed. ### 4. Timer.DelayCall Still Exists -`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for `[DeserializeTimerField]`) or state-carrying overloads. +`Timer.DelayCall()` is still available and returns a `Timer` object. Use it when you need the `Timer` reference (e.g., for a serialized timer field with `[DeserializeTimer]`) or state-carrying overloads. ### 5. Custom Timer Classes Are Still Possible For complex timer logic (e.g., `Corpse.DecayTimer`), you can still subclass `Timer` with `OnTick()`. But prefer the fire-and-forget pattern for simple cases. diff --git a/dev-docs/serialization.md b/dev-docs/serialization.md index 151d9d717..059fbff69 100644 --- a/dev-docs/serialization.md +++ b/dev-docs/serialization.md @@ -117,7 +117,7 @@ public partial class MyItem : Item { } See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration guidance. -### [SerializableField(index, setter, saveIf)] +### [SerializableField(index, getter, setter, isVirtual, fieldChanged, allowFieldChange)] **Target**: Private field (`_camelCase`) **Generates**: Public `PascalCase` property with get/set @@ -125,8 +125,11 @@ See `dev-docs/runuo-migration-docs/02-serialization.md` for complete migration g | Parameter | Type | Default | Description | |---|---|---|---| | `index` | `int` | Required | Serialization order (0-based) | -| `setter` | `string` | `null` (public) | `"private"` or `"internal"` to restrict setter | -| `saveIf` | `string` | `null` | Method name returning bool for conditional save | +| `getter` | `string` | `"public"` | Getter accessibility | +| `setter` | `string` | `"public"` | `"private"` or `"internal"` to restrict setter | +| `isVirtual` | `bool` | `false` | Generate a `virtual` property | +| `fieldChanged` | `string` | `null` | `nameof` of a `void Method(T oldValue, T newValue)` invoked by the generated setter after assignment | +| `allowFieldChange` | `string` | `null` | `nameof` of a `bool Method(ref T value)` invoked before assignment; coerce the value through the `ref` parameter, or return `false` to reject the change | ```csharp [SerializableField(0)] // Public property @@ -144,14 +147,57 @@ The generated property for `_charges` would be: public int Charges { get => _charges; - set { _charges = value; this.MarkDirty(); } + set + { + if (value != _charges) + { + _charges = value; + this.MarkDirty(); + } + } } ``` +**Setter hooks** replace most hand-written `[SerializableProperty]` setters. The generated +pipeline is: equality check → `allowFieldChange` (coerce/veto) → assignment → `MarkDirty` → +`InvalidateProperties` (if declared) → `fieldChanged`. The gate runs before assignment, so +the field itself still holds the old value inside it. + +```csharp +[SerializableField(0, allowFieldChange: nameof(AllowChargesChange))] +[SerializedCommandProperty(AccessLevel.GameMaster)] +[InvalidateProperties] +private int _charges; + +private bool AllowChargesChange(ref int value) +{ + value = Math.Clamp(value, 0, MaxCharges); // coerce, or return false to veto + return true; +} + +[SerializableField(1, fieldChanged: nameof(OnOwnerChanged))] +private Mobile _owner; + +// oldValue makes unsubscribe/resubscribe patterns trivial +private void OnOwnerChanged(Mobile oldValue, Mobile newValue) +{ + oldValue?.Followers.Remove(this); + newValue?.Followers.Add(this); +} +``` + +Both hooks require a generated setter — declaring one on a `readonly` field or with +`setter: null` is a compile-time error (SG3018), and a named method that is missing or has +the wrong signature is too (SG3015). + ### [SerializableProperty(index, useField)] **Target**: Property with custom get/set logic -**Use when**: You need non-trivial getter/setter logic +**Use when**: You need a **custom getter** (fallback defaults, lazy or self-healing reads) +or setter semantics the field hooks cannot express (work that must run on *equal* +assignment, pre-assignment state capture). For setters that only coerce, veto, or run +post-change side effects, prefer `[SerializableField]` with `allowFieldChange`/`fieldChanged` +instead — the generated setter handles equality, `MarkDirty`, and ordering for you. | Parameter | Type | Default | Description | |---|---|---|---| @@ -163,7 +209,7 @@ public int Charges [CommandProperty(AccessLevel.GameMaster)] public int MaxItems { - get => _maxItems == -1 ? DefaultMaxItems : _maxItems; + get => _maxItems == -1 ? DefaultMaxItems : _maxItems; // custom getter: the reason this is a property set { _maxItems = value; @@ -173,6 +219,10 @@ public int MaxItems } ``` +Note: the `fieldChanged`/`allowFieldChange` hooks are `[SerializableField]` arguments and +cannot be declared on a `[SerializableProperty]` — its setter is your own code, so call your +methods from the setter directly. + ### [InvalidateProperties] **Target**: `[SerializableField]`-decorated field @@ -208,12 +258,32 @@ Overloads: Best for fields that are usually small values (counts, IDs, indexes). +### [AnchoredDateTime] + +**Target**: `DateTime` field +**Effect**: Stores the absolute UTC instant; at load it is shifted forward by the downtime +between the save and the load (using the save-start anchor in the save's index file), so +server downtime does not consume the remaining time. `DateTime.MinValue`/`MaxValue` +sentinels pass through unshifted. + +Prefer this for deadlines and "elapsed while running" values. Unlike `[DeltaDateTime]`, the +stored bytes do not change on every save when the value is unchanged, keeping idle saves +byte-stable. + +```csharp +[AnchoredDateTime] +[SerializableField(0)] +private DateTime _expireTime; +``` + ### [DeltaDateTime] **Target**: `DateTime` field **Effect**: Stores as offset from current time rather than absolute timestamp. -This ensures timers and expiration dates survive server restarts correctly. +Legacy encoding for surviving restarts: it rewrites the bytes on every save even when the +value has not changed. Prefer `[AnchoredDateTime]` for new fields; converting an existing +field between the two changes the wire format and requires a version bump. ```csharp [DeltaDateTime] @@ -289,40 +359,76 @@ private void AfterDeserialization() } ``` -### [DeserializeTimerField(fieldIndex)] +### [DeserializeTimer(nameof(Method), wallClock)] -**Target**: Method taking `TimeSpan` parameter -**Effect**: Custom deserialization for Timer fields. The timer is saved as remaining delay. +**Target**: `Timer`-typed `[SerializableField]` or `[SerializableProperty]` member +**Effect**: Declares how the timer is stored and restored. Required on every serializable +timer (SG3008 otherwise). + +By default the timer's next tick is stored as **anchored time**: server downtime does not +consume the remaining delay, and idle saves are byte-stable. Pass `wallClock: true` to store +an absolute deadline instead (the delay is then negative when the deadline passed during +downtime). + +The named method — `void Method(TimeSpan delay)` — is invoked **only when a timer was +actually running at save**, with the remaining delay. There is no sentinel value to check. ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -[DeserializeTimerField(0)] -private void DeserializeDecayTimer(TimeSpan delay) +private void DeserializeDecayTimer(TimeSpan delay) => _decayTimer = Timer.DelayCall(delay, Delete); +``` + +Switching an existing timer between drifting and `wallClock` changes the wire format — bump +the class version and add a `MigrateFrom`. The old-version content struct exposes the +timer's `XxxNext` (`DateTime`) and `XxxDelay` (`TimeSpan`, `TimeSpan.MinValue` when no timer +was running): + +```csharp +private void MigrateFrom(V3Content content) { - _decayTimer = Timer.DelayCall(delay, Delete); - _decayTimer.Start(); + if (content.DecayTimerDelay != TimeSpan.MinValue) + { + DeserializeDecayTimer(content.DecayTimerDelay); + } } ``` -### [SerializableFieldSaveFlag(fieldIndex)] / [SerializableFieldDefault(fieldIndex)] +### [SaveFlag(nameof(ShouldSerializeMethod), nameof(DefaultValueMethod))] +**Target**: the serializable field or property itself **Conditional serialization** -- skip fields that have their default value. +The first method (`bool Method()`) decides whether the value is written. The optional second +method (returning the field's type, no parameters) supplies the value at load when it was +not written; when omitted, the field keeps its default value. + +```csharp +[SerializableField(0)] +[SaveFlag(nameof(ShouldSerializeCharges), nameof(ChargesDefaultValue))] +private int _charges; + +private bool ShouldSerializeCharges() => _charges != -1; + +private int ChargesDefaultValue() => -1; +``` + +Works on `[SerializableProperty]` members the same way: + ```csharp [EncodedInt] [SerializableProperty(0)] +[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))] public int MaxItems { get => _maxItems == -1 ? DefaultMaxItems : _maxItems; set { _maxItems = value; this.MarkDirty(); } } -[SerializableFieldSaveFlag(0)] private bool ShouldSerializeMaxItems() => _maxItems != -1; -[SerializableFieldDefault(0)] private int MaxItemsDefaultValue() => -1; ``` diff --git a/dev-docs/server-requirements.md b/dev-docs/server-requirements.md new file mode 100644 index 000000000..0b2c50569 --- /dev/null +++ b/dev-docs/server-requirements.md @@ -0,0 +1,121 @@ +# Server Requirements + +Hardware guidance for running a ModernUO shard. + +## Tiers + +| Use | vCPU | RAM | Storage | +|---|---|---|---| +| Development / test | 2 **dedicated** | 2 GB | SSD | +| Small live shard (< 50 concurrent) | 4 dedicated | 4 GB | NVMe | +| Medium (50–200) | 4–8 | 8 GB | NVMe | +| Large (200+) | 8+, high clock | 16 GB+ | NVMe | + +These are starting points. Save size drives RAM more than player count does, and single-thread +clock speed drives tick latency more than core count does. Both are explained below. + +## Dedicated vCPU, not burstable + +This matters more than any other line on this page. + +Budget VPS plans sold as "2 vCPU" are frequently shared or burstable: you get a CPU credit balance +or a cgroup quota, and once it is exhausted the hypervisor throttles you. Throttling shows up in +game as periodic freezes that correlate with nothing in your logs, and it is the single most common +cause of "ModernUO is laggy on my $3/month VPS". + +Symptoms worth checking before blaming the server: + +- Steal time above ~1% (`top`, the `%st` column on Linux) +- Lag that disappears when you move to a larger plan with the same core count +- Tick lag spikes with no matching CPU spike in the process itself + +## Cores + +Game logic is **single-threaded**. Every mobile, item, timer, and packet handler runs on one +thread, so a shard's headroom is bounded by how fast one core is. Two fast cores beat four slow +ones. + +Cores beyond the first are used by: + +- **World saves.** `world.useMultithreadedSaves` (default on) spins up `ProcessorCount - 1` + serialization workers plus one inline on the main thread. On a 2-core box that is one worker; on + a 2-core box with a large world, consider setting it to `false` so saves do not contend with the + loop. +- **The .NET runtime.** Tiered JIT compilation (heaviest in the first minutes after boot) and + background GC. +- **Everything else on the machine**, including your OS and, on Windows, antivirus. + +Since ModernUO 2026 the loop sleeps when idle, so an empty shard costs roughly 1% of a core rather +than spinning. That change disproportionately helps small hosts. + +## Memory + +Three things dominate, and only one of them scales with players. + +**World size.** A world of ~190,000 items and ~33,000 mobiles loads in about a second and is not +itself large. Items and mobiles are the cheap part. + +**Saves.** Each serialization worker pre-allocates a heap sized to its share of the last save, at +roughly 1.25× total save size, and those buffers are retained afterwards. A 400 MB save therefore +implies about 500 MB of resident serialization heap on top of the live world. **This is the reason +1 GB hosts are not viable for a real shard**, even though an empty one boots fine. + +**Map residency.** `TileMatrix` reads map blocks from disk on demand and caches them permanently — +there is no eviction. Memory climbs toward full-facet residency as players explore. Felucca's land +tiles alone are around 117 MB, and statics are larger. + +Optional systems can add substantially more. The pathfinding prebake +(`pathfinding.prebakeMaps`) peaks above 1 GB of heap while baking. Budget for it or leave it off on +small hosts. + +Network buffers are minor by comparison: 64 KB receive plus a configurable 256 KB send +(`network.sendBufferSize`) per connection, so 100 players is roughly 32 MB. + +ModernUO runs **Workstation GC**, which is the right default for small hosts. Do not switch to +Server GC on a 2-core box. + +## Storage + +Saves are write-heavy bursts. Cheap network-attached storage with throttled IOPS will stall the +save path, and `World.WaitForWriteCompletion` blocks the loop at shutdown. Use local NVMe or SSD. + +Budget disk for: the world save, plus archives and backups if `autoArchive` is enabled (retention +defaults keep 24 hourly, 30 daily, and 12 monthly copies), plus the pathfinding cache if enabled. + +## Operating systems + +See the README for the full supported list. Two things are worth calling out: + +- **Windows Server 2012 R2 and 2016 sleep via a raised timer resolution.** Sleeping for a couple + of milliseconds prefers a high-resolution waitable timer, which requires Windows 10 1803 / + Server 2019. On older versions the ring falls back to `timeBeginPeriod(1)`, which raises the + system timer resolution to 1 ms so the plain wait timeout is accurate enough. The trade-off is a + higher interrupt rate (system-wide on those versions) — an acceptable price on a dedicated game + server, and the reason the high-resolution timer is preferred where it exists. + + Only if *both* mechanisms fail does the server detect it at startup, log it, and spin instead — + the same behaviour as setting `server.eventLoopIdleWaitMs` to 0: a full core at idle, and zero + missed deadlines. A host that claims short waits but cannot deliver them is caught at runtime by + the adaptive backoff. +- **Linux kernel 6.1** or newer (Debian 12 and equivalents). io_uring is used where available, with + automatic epoll fallback. + +## Tuning for a small host + +| Setting | Default | Why change it | +|---|---|---| +| `server.eventLoopIdleWaitMs` | `2` | `0` never sleeps: ~98% of one core, but zero skipped timer slots and zero lag. The choice for a large shard on dedicated CPU that would rather spend a core than risk a late wake. Above `2` the wheel starts losing slots. | +| `server.lateWakeThreshold` | `1` | Floor for the backoff: idle waits the host may return a full tick late, per second, before the rate test below applies at all. Raise on a jittery host; set very high to disable the backoff. | +| `server.lateWakePercent` | `10` | Share of a second's idle waits that must come back late before idle sleeping backs off. An idle loop sleeps hundreds of times a second, so a bare count cannot tell a few tail outliers from a host that never schedules the process — a genuinely bad host misses *most* of its waits. `0` leaves `lateWakeThreshold` in sole charge. | +| `world.useMultithreadedSaves` | `true` | Set `false` on 2-core hosts so saves do not contend with the game loop. | +| `pathfinding.prebakeMaps` | varies | Leave off on memory-constrained hosts; it peaks above 1 GB while baking. | +| `network.sendBufferSize` | 256 KB | Lower it if you are memory-bound with many connections. | +| `autoArchive.*` retention | 24h/30d/12m | Reduce if disk is tight. | + +## Am I undersized? + +Watch the log. The server warns when the host returns idle waits late and suspends idle sleeping, +and says so at startup if the host cannot honour short waits at all. Those warnings mean the host +is not scheduling the process promptly — typical of burstable or shared vCPU plans — and no +server-side change fixes that. For anything deeper, see +[debugging-event-loop.md](debugging-event-loop.md). diff --git a/dev-docs/threading-model.md b/dev-docs/threading-model.md index 09b0ec8f4..d89bc1c61 100644 --- a/dev-docs/threading-model.md +++ b/dev-docs/threading-model.md @@ -130,6 +130,138 @@ These files in `Projects/Server/` MAY use threading because they handle I/O outs - `Timer/Timer.Pool.cs` -- Async pool refill - `EventLoopTasks.cs` -- The synchronization context itself +### Exceptions: Vetted Workers in `Projects/UOContent/` + +**Take great care here. A background thread is a last resort, not a tool of first choice.** + +The table above is about **game logic**, which is never threaded. A dedicated worker that touches +no game state is the sanctioned way to move CPU-heavy or I/O work off the loop, and it necessarily +uses primitives the table forbids -- `new Thread`, `ConcurrentQueue`, `Interlocked`, +`AutoResetEvent`, `volatile`. Those are legitimate **at the thread boundary**, and nowhere else. + +#### First: prove the need + +Do not add a worker because something "looks slow". Measure, and measure the right thing: + +- **Measure on-loop time, not wall-clock.** How long a player waits does not matter; how long the + world is frozen does. A change that improves latency but not loop time buys nothing. +- **Off-loading does not create CPU.** It converts "the loop is blocked for N ms" into "the loop + competes for cores for N ms". On a 1--2 core host there is no spare core and it buys nothing at + all -- gate on `Environment.ProcessorCount`. +- **Account for what stays behind.** Dispatch, the continuation, and the loop's own work slowing + down while the worker evicts shared L3. That last one is real and is usually the largest. +- **Write the benchmark down.** A worker with no recorded measurement cannot be re-justified later, + and will be removed by someone who cannot tell whether it earns its complexity. + +#### Game logic stays on the loop -- chunk it instead + +Work that **needs** game state cannot be threaded at any core count. If it is too slow for one +tick, split it across ticks rather than across threads: + +```csharp +// Bound the work per tick, resume where it left off. +Timer.DelayCall(TimeSpan.Zero, TimeSpan.FromMilliseconds(50), () => +{ + var budget = 0; + while (_cursor < _items.Count && budget++ < 100) + { + Process(_items[_cursor++]); + } +}); +``` + +Bound by count or elapsed time, never by "until done". Threading game state is not a faster +version of this -- it is a correctness bug. + +#### Vetted workers + +| Worker | Off-loop work | Justification | +|---|---|---| +| `Accounting/Security/PasswordWorker.cs` | Password verification and hashing | `docs/handoffs/2026-08-07-off-loop-argon2-hashing.md` -- 8.9 ms/login on-loop at Argon2, measured 3.5--8.9 ms saved | +| `Engines/Advanced Search/AdvancedSearchGump.cs` | Parallel entity search | Admin-triggered full-world scan; saves disabled for its duration | + +Adding to this table needs the same bar: a measurement, and all five rules below. + +#### The six rules + +1. **No game state off-thread, read or written.** Hand the worker immutable values (strings, + structs) captured on the loop. Carrying a reference is fine only if the worker just passes it + back untouched. +2. **Decide policy on the loop, compute on the worker.** Anything rule-dependent -- which algorithm, + which salt, which era branch -- is resolved at dispatch, so the worker holds no policy it could + apply inconsistently. +3. **Park on a kernel wait; never spin.** `AutoResetEvent.WaitOne()` costs nothing while idle. + `SerializationThreadWorker` does spin, but only to await a producer mid-drain; absent that race, + spinning is a bug that burns a core on shared hosts. +4. **Yield to world saves.** Run only while `WorldState is Running or WritingSave`. `World.Saving` + is *not* the right check -- it covers only the freeze and misses `PendingSave`, where the + serialization threads are already awake and spinning on an empty queue. +5. **Bound the queue**, or rely on a bound upstream and say which one in a comment. +6. **Everything the worker calls must itself be safe off-thread.** A process-wide singleton is not + automatically safe -- look for instance state. `HashAlgorithm.ComputeHash` carries the running + digest across `HashCore`/`HashFinal`, so two threads sharing one corrupt each other. `Utility`'s + RNG is a shared `System.Random`, which is both thread-unsafe and game state. Prefer the static + one-shot forms (`SHA256.HashData`, `RandomNumberGenerator.Fill`), and if a dependency cannot be + made safe, fix it at the source rather than narrowing the worker around it. + +#### Handing work across the boundary + +**Loop → worker (dispatch).** Snapshot everything needed into immutable values. Capture any value +you intend to overwrite later, so the continuation can tell whether it changed: + +```csharp +var job = new Job +{ + Target = state, // carried, never dereferenced off-thread + Expected = account.Password, // captured so the continuation can detect a change + Input = DerivePhrase(...) // policy resolved here, on the loop +}; + +if (!Worker.TryEnqueue(job)) +{ + // Full. Reject -- do not fall back to running it inline, or a flood steers the work + // straight back onto the loop. +} +``` + +**Worker → loop (hand back).** Two sanctioned routes, and no others: + +```csharp +// 1. Marshal the apply step. Preferred when a specific result belongs to a specific caller. +Core.LoopContext.Post(() => Apply(job, result)); + +// 2. Publish an immutable snapshot behind a single volatile reference, read lock-free by the loop. +// Preferred for a shared lookup table rebuilt periodically. +Volatile.Write(ref _snapshot, newTable); +``` + +**The continuation must re-validate.** Time passed, and the loop kept running: + +```csharp +private static void Apply(Job job, Result result) +{ + // Gone? Never revive a dead NetState or a deleted entity. + if (job.Target?.Running != true) + { + return; + } + + // Changed? Do not overwrite a newer value with one derived from an older one. + if (!string.Equals(account.Password, job.Expected, StringComparison.Ordinal)) + { + return; + } + + account.Apply(result); +} +``` + +**Always post a result, including on failure.** A worker that throws and posts nothing leaves +whatever awaited it waiting forever. Catch, log, and post a failure verdict. + +**Never** call into game state from the worker, and never `await` on the loop in a way that lets a +continuation resume heavy work there -- `ConfigureAwait(false)` on every await inside off-loop work. + ## Memory Pooling ### STArrayPool diff --git a/dev-docs/tick-counts.md b/dev-docs/tick-counts.md new file mode 100644 index 000000000..f53e223d4 --- /dev/null +++ b/dev-docs/tick-counts.md @@ -0,0 +1,55 @@ +# Tick Counts: Overflow and Huge Starting Values + +Rules for any code that compares `Core.TickCount` / `Core.GetTimestamp()` values. Getting this +wrong produces bugs that only appear on specific cloud hosts after long host uptimes — the worst +kind to reproduce. + +## Why this matters (the Linux/cloud problem) + +`Core.GetTimestamp()` is built on `Stopwatch.GetTimestamp()`, which on Linux reads the kernel's +monotonic clock — and on some hypervisors, notably **Google Cloud**, the VM receives a +**pass-through of the host's never-resetting counter**. The tick count is *not* zero when the +process starts and *not* zero when the operating system booted; it is however long the physical +host has been up, which can be months or years. We have been burned by this in production. + +Consequences: + +- Raw values are enormous from the first read. Arithmetic that would "never overflow in 292 + years" of process uptime can overflow immediately (`Core.GetTimestamp()`'s `UInt128` + conversion path exists precisely because `raw * 1000` does not fit in 64 bits for large raws). +- Wrapped values can be **negative**. Nothing may assume a tick count is positive. +- **Windows is not affected** in our testing so far, which is exactly why this class of bug + ships: it works on every dev machine and fails on a customer's GCP instance. + +## The rules + +1. **Compare by subtraction, never directly.** Subtraction of two ticks wraps correctly in two's + complement; direct comparison does not. + + ```csharp + // WRONG: fails when ticks wrap or start huge + if (Core.TickCount < deadline) + + // RIGHT: wraparound-safe + if (Core.TickCount - deadline < 0) + ``` + +2. **Durations are always subtractions of two readings** (`elapsed = end - start`). Never derive + a duration from a single absolute value. + +3. **No zero or sign sentinels.** `if (_lastEventAt > 0)` as "has this happened yet" breaks when + ticks are negative. Track "has happened" with a separate `bool` or an existing counter. + +4. **Seed deadline fields from a real tick, not from field initialization.** A `long _deadline;` + left at 0 compares wrong against a huge or negative tick. Initialize relative to the first + observed timestamp (see the schedule-state seeding in `Core.Setup`). + +5. **Store deadlines as `start + interval` only if every comparison follows rule 1.** The + addition may wrap; the subtraction comparison handles it. + +## Reviewing for it + +Grep the diff for `TickCount <`, `TickCount >`, `GetTimestamp() <`, and comparisons against any +field whose name suggests a deadline (`*Until`, `*At`, `*Next*`). Each hit must be in subtraction +form. `DateTime`/`DateTimeOffset` comparisons are unaffected; this applies only to the monotonic +tick domain. diff --git a/dev-docs/timers.md b/dev-docs/timers.md index c10f31bd6..2c051cca6 100644 --- a/dev-docs/timers.md +++ b/dev-docs/timers.md @@ -188,15 +188,19 @@ public partial class TimedItem : Item ``` ### Pattern 4: Serializable Timer Field +Every serializable `Timer` member declares `[DeserializeTimer(nameof(Method))]` on the +field. By default the next tick is stored as anchored time (downtime does not consume the +remaining delay); pass `wallClock: true` for absolute deadlines. The method runs **only when +a timer was running at save**, with the remaining delay. + ```csharp [SerializableField(0, setter: "private")] +[DeserializeTimer(nameof(DeserializeDecayTimer))] private Timer _decayTimer; -[DeserializeTimerField(0)] private void DeserializeDecayTimer(TimeSpan delay) { _decayTimer = Timer.DelayCall(delay, Delete); - _decayTimer.Start(); } public void BeginDecay(TimeSpan delay) diff --git a/tools/Export-IpBlocklist.ps1 b/tools/Export-IpBlocklist.ps1 new file mode 100644 index 000000000..0e9fd781d --- /dev/null +++ b/tools/Export-IpBlocklist.ps1 @@ -0,0 +1,1082 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Downloads a small, non-overlapping set of public IP threat feeds and writes them to a single + ModernUO blocklist file -- merged, de-duplicated and bogon-filtered. + +.DESCRIPTION + This is the producer half of ModernUO's in-app blocklist gate. It fetches a deliberately THIN feed + set, merges every source into one global set, drops duplicates and reserved/bogon addresses, then + writes the result to a plain text file that the shard reads via `file` in + Configuration/blocklist.json. Nothing is installed and no credentials are needed -- the output is just + a text file, so this can run on any machine that can reach the shard's Distribution folder. + + It writes the file the shard's `BlocklistFilter` demand-pages against. IPs that actually connect are + promoted to CrowdSec / the OS firewall by the shard; the OS firewall never has to hold millions of + entries, which is exactly the scale it cannot handle on Windows. + + Inclusion principle: any category of IP used in OTHER attacks that could plausibly be turned against a + game server should be blocked -- compromised hosts, botnets, scanners, spam / DDoS-as-a-service bots, + open proxies and Tor relays. That whole surface is already covered by the anchor feed `bitwire-it`, + which is itself a 91-source aggregator (it folds in spamhaus, ipsum, firehol-level2, blocklist-de, + dshield, emergingthreats, binarydefense, cins-army, bruteforceblocker, greensnow, vxvault, ThreatFox, + StopForumSpam/sblam, Tor, open-proxy and C2 lists). So the inclusive posture lives in the base layer, + and every one of those standalone feeds is dropped as pure redundancy. Only the feeds bitwire does NOT + already carry are kept on top of it: + + bitwire-it 2h-refreshed 91-source aggregate (compromised hosts, botnets, scanners, spam + bots, Tor/open-proxy abuse relays, ThreatFox C2) -- the broad base layer. + romainmarcoux ~130k fresh attacker IPs bitwire's snapshot lags on (high-churn feed). + sentinel-turris ~800 unique honeypot probers (Turris greylist) not in bitwire. + firehol-level1 hijacked/reputation NETBLOCKS (spamhaus DROP-style) -- bogon-filtered. + + The only category deliberately held back is commercial VPN exit endpoints, which could block a legit + player -- and those are barely present here anyway (bitwire is ~5% of VPN-tunnel lists). If you ever want + to protect VPN/Tor players, pass -ExcludeAnonymizers to subtract Tor/open-proxy/VPN IPs from the output. + + ALLOWLIST + Aggregators inevitably list shared consumer address space. A CGNAT public IP fronts many subscribers, so + one abusive customer gets the address listed and every other subscriber behind it is blocked with them. + Entries in the allowlist file (-AllowlistFile) are SUBTRACTED from the merged set before it is written, so + the exemption costs nothing on the shard's accept path -- which is deliberately allowlist-free, because a + whitelist there could only ever turn a deny into an allow at the price of a lookup on every accept, the + attacker's included. Allowlisting belongs here (at generation) and at the enforcement layer + (`cscli allowlists`), never at the gate. + + Subtraction is range-correct: an allowlisted address that falls inside a blocked CIDR splits that CIDR + around the hole instead of being silently ignored, so an exemption always takes effect no matter which + shape the feed happened to publish. + + Every `ip-allowlist*.txt` beside the output is subtracted, so allowlists are split by owner rather than + kept in one file: `ip-allowlist.txt` holds the operator's own exemptions and is never rewritten, while + network carve-outs live in `ip-allowlist-.txt`. Keeping them apart means a carve-out can be + regenerated, diffed or copied to another shard without disturbing hand-written entries. + + NO CARVE-OUT IS SHIPPED. Which providers to exempt is a policy call that depends on where a shard's + players actually are, and a carve-out names a real network, so this script builds them on request rather + than publishing anyone's. A shard whose players are on CGNAT -- satellite, mobile, or an ISP short on + IPv4 -- will usually want one: + + .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 + + That costs roughly 0.1% of the list. Abusive hosts inside a carved-out network are still caught on + BEHAVIOR by the rate limiter and promoted to CrowdSec, which is the gate that actually observes them. + + OUTPUT FORMAT (must stay in sync with UOContent/Network/Blocklist/BlocklistFile.cs): + Line 1 is a header comment carrying the version markers, e.g. + # modernuo-blocklist generated=2026-07-25T18:03:11Z count=3914022 ipv4=3901188 cidr=12834 + The shard polls `reloadInterval` and reloads when the file mtime AND `generated=` change, + so the header is REQUIRED -- without it the shard loads once and never picks up a new file. + Every following line is one entry: a bare IPv4/IPv6 address or a CIDR (`1.2.3.0/24`). Blank lines + and lines starting with `#` or `;` are ignored. Order does not matter; the shard sorts and + coalesces on load. The feeds used here are IPv4-only, but the shard parses IPv6 lines too. + + The file is written to a `.tmp` sibling and swapped into place atomically, so the shard never reads a + half-written list -- it either sees the previous version or the new one, whole. + + Performance: bitwire alone is ~4M lines. Parsing/validating/bogon-filtering that in interpreted + PowerShell is the slow part (minutes), so the hot loop is compiled once via Add-Type (C#) -- it runs in + ~1s. Downloads stream with a live Write-Progress bar; every phase prints its own elapsed time so you can + see exactly where the wall-clock goes. + + Requires PowerShell 7 (pwsh), which runs on Windows, Linux and macOS -- Windows PowerShell 5.1 is + not supported and the script refuses to run there. Schedule it with Task Scheduler, cron, or a + systemd timer. + + Every run rewrites the whole file, so an IP that drops off the feeds stops being blocked on the next + run -- there is no TTL to tune. Calling it is idempotent: if the list on disk is younger than + -MinInterval the script exits without downloading anything, so an over-eager trigger costs nothing + upstream. -Force overrides that. + +.PARAMETER DistributionPath + Path to the shard's Distribution folder. The blocklist is written to the Configuration/ip-blocklist.txt + beneath it, which is the default `file` in blocklist.json. Not needed when the script is run from its + place in the repo (tools/), or when -OutFile is given. + +.PARAMETER OutFile + Explicit output path, overriding -DistributionPath. Use this if you relocated the blocklist and + changed `file` in blocklist.json to match. + +.PARAMETER MinInterval + Refuse to re-run while the existing blocklist is younger than this (default 2h), so a misbehaving + scheduler, a login script or a stuck retry loop cannot hammer the upstream feeds. The age comes from + the `generated=` header of the file already on disk (falling back to its mtime), so it survives across + machines and reboots -- there is no separate state file. Nothing is downloaded when the check trips. + Accepts `90s`, `45m`, `2h`, `2.5h`, `1d`, or a bare number of hours. Use `0` to disable the check. + Match this to how often you actually want fresh data: the anchor feed only refreshes every 2h, so + running more often than that costs bandwidth and gains nothing. + +.PARAMETER Force + Run regardless of how recently the blocklist was generated (bypasses -MinInterval). + +.PARAMETER Feeds + Which feeds to include (by Name). Default: all of them. + +.PARAMETER AllowlistFile + One or more lists of addresses that must NEVER be blocked; every entry is subtracted from the merged set + before the output is written. Same format as the blocklist: one bare IPv4 or CIDR per line, `#`/`;` + comments ignored. + + Defaults to every `ip-allowlist*.txt` beside the output, merged into one allow set: + ip-allowlist.txt operator exemptions -- created on first run, never rewritten + ip-allowlist-.txt a network carve-out -- generated data, safe to regenerate or copy + Discovered rather than configured, so a carve-out you add is picked up with no further edits. Blank a + file (keep the file) to disable its contents; delete it to drop it entirely. + + Passing this parameter replaces the defaults entirely; an explicitly-named file that does not exist is a + warning rather than a silent template write, so a typo cannot look like it worked. Pass `''` to disable + subtraction altogether. + + Editing any allowlist also bypasses -MinInterval on the next run: an exemption you just added would + otherwise sit unapplied for up to the cooldown, which reads exactly like the allowlist not working. + +.PARAMETER AddCarveout + Build a carve-out for a network and start subtracting it. Takes a short name for the file and -Asn for + the network, fetches that ASN's current routing announcements, collapses them, and writes + `ip-allowlist-.txt` beside the output. Implies -Force. + + .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 + + No carve-out ships with this script: naming a network to exempt is a policy call for the shard, so they + are built on request rather than published here. Re-running with the same name rebuilds the file. + +.PARAMETER Asn + Autonomous system number for -AddCarveout, e.g. 14593 for Starlink. Look one up by querying an address + the network hands out, or on any public BGP lookup. + +.PARAMETER RefreshCarveouts + Re-fetch every carve-out beside the output and rewrite it from current routing announcements. Implies + -Force. Carve-outs are recognised by the `asn=` marker in their header, so a hand-written allowlist is + left alone, and one whose fetch fails keeps the data it already had. + + Announcements rather than ownership records on purpose: registry data disagrees with what is actually + routed, and registry queries silently cap their result sets. + +.PARAMETER ExcludeAnonymizers + Also download Tor-exit / open-proxy / VPN-tunnel lists and SUBTRACT those IPs from the output. Off by + default -- for a game server, Tor/open-proxy relays are attack infrastructure you want to block. Turn + this on only if you need to keep VPN/Tor players reachable. + +.PARAMETER DryRun + Download + parse + merge + count only. Writes nothing. + +.EXAMPLE + .\Export-IpBlocklist.ps1 -DryRun + +.EXAMPLE + # Safe to call as often as you like -- it no-ops unless the list is older than 2h. + .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' + +.EXAMPLE + .\Export-IpBlocklist.ps1 -OutFile 'D:\shared\ip-blocklist.txt' -ExcludeAnonymizers + +.EXAMPLE + # Regenerate right now, ignoring the cooldown. + .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' -Force + +.EXAMPLE + # Unblock a player caught by a shared-IP listing: add the address, then regenerate. Editing the + # allowlist bypasses the cooldown, so no -Force is needed. + Add-Content 'C:\Shard\Distribution\Configuration\ip-allowlist.txt' '203.0.113.42' + .\Export-IpBlocklist.ps1 -DistributionPath 'C:\Shard\Distribution' + +.EXAMPLE + # Check what an allowlist would cost before committing to it. + .\Export-IpBlocklist.ps1 -AllowlistFile 'D:\shared\allow.txt' -DryRun + +.EXAMPLE + # Exempt a CGNAT provider whose players keep getting listed, then keep it current. + .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 + .\Export-IpBlocklist.ps1 -RefreshCarveouts + +.EXAMPLE + # Linux/macOS, e.g. from cron: + pwsh -File /opt/modernuo/Export-IpBlocklist.ps1 -DistributionPath /opt/modernuo/Distribution + +.NOTES + Feeds are aggressive-but-low-FP for a game server (attacker / botnet / compromised / abuse-relay SOURCE + IPs). Reserved/bogon space (0/8, 10/8, 127/8, RFC1918, multicast, etc.) is always filtered out -- this + matters because firehol-level1 ships bogon netblocks that would otherwise block private/reserved ranges. +#> +[CmdletBinding()] +param( + [string] $DistributionPath, + [string] $OutFile, + [string] $MinInterval = '2h', + [string[]] $AllowlistFile, + [string] $AddCarveout, + [int] $Asn, + [switch] $RefreshCarveouts, + [string[]] $Feeds, + [switch] $ExcludeAnonymizers, + [switch] $Force, + [switch] $DryRun +) + +$ErrorActionPreference = 'Stop' + +# Fail before any download rather than after 60MB of feeds. +if ($AddCarveout -and $Asn -le 0) { + throw "-AddCarveout needs -Asn, e.g. -AddCarveout starlink -Asn 14593" +} +$UA = 'ModernUO-Blocklist-Export' +$totalSw = [System.Diagnostics.Stopwatch]::StartNew() + +# Default location under the Distribution folder. Keep in sync with BlocklistSettings.File. +# Kept as separate segments (never a literal 'a\b') so Join-Path picks the right separator per OS. +$DefaultPathSegments = @('Configuration', 'ip-blocklist.txt') + +# --------------------------------------------------------------------------------------------------------- +# Resolve the output path. Explicit -OutFile wins; then -DistributionPath; then the in-repo layout +# (tools\ sits next to Distribution\) so a checkout works with no arguments at all. The script is meant to +# be copied onto the shard host, and there it needs -DistributionPath (or -OutFile). +# --------------------------------------------------------------------------------------------------------- +if (-not $OutFile) { + if (-not $DistributionPath -and $PSScriptRoot) { + $inRepo = Join-Path (Split-Path -Parent $PSScriptRoot) 'Distribution' + if (Test-Path -LiteralPath $inRepo -PathType Container) { $DistributionPath = $inRepo } + } + if (-not $DistributionPath) { + throw "Could not locate the shard's Distribution folder. Pass -DistributionPath 'C:\path\to\Distribution' (or -OutFile)." + } + if (-not (Test-Path -LiteralPath $DistributionPath -PathType Container)) { + throw "DistributionPath '$DistributionPath' does not exist." + } + $OutFile = Join-Path $DistributionPath @DefaultPathSegments +} + +# --------------------------------------------------------------------------------------------------------- +# Network carve-outs are DATA, not code: this script ships none. Which providers a shard exempts is a policy +# call that depends on where its players actually are, so the carve-outs live in files an admin creates with +# -AddCarveout, and every ip-allowlist*.txt beside the output is subtracted. +# +# A shard whose players are on CGNAT -- satellite, mobile, or an ISP short on IPv4 -- will usually want one: +# .\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593 +# --------------------------------------------------------------------------------------------------------- + +# --------------------------------------------------------------------------------------------------------- +# Resolve the allowlists. They sit beside the output by default so relocating the blocklist keeps the set +# together, and they are split by owner: `ip-allowlist.txt` is the operator's -- hand-edited, never rewritten +# -- while each carve-out file is generated data that can be regenerated, diffed or copied between shards +# without touching anyone's local exemptions. +# +# Carve-outs are discovered rather than listed, so a file an admin drops in is picked up with no config edit +# and no code change. An EXPLICIT -AllowlistFile replaces the whole set and is never templated: if the +# operator names a file, a missing one is a typo worth hearing about. +# --------------------------------------------------------------------------------------------------------- +$AllowGlob = 'ip-allowlist*.txt' +$ConfigDir = Split-Path -Parent $OutFile + +$allowExplicit = $PSBoundParameters.ContainsKey('AllowlistFile') + +if ($allowExplicit) { + $AllowPaths = @($AllowlistFile | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +} +else { + $AllowPaths = @(Get-ChildItem -Path $ConfigDir -Filter $AllowGlob -File -ErrorAction SilentlyContinue | + Sort-Object Name | ForEach-Object { $_.FullName }) +} + +function Write-AllowlistFile { + param([string]$Path, [string[]]$Lines) + + $dir = Split-Path -Parent $Path + if ($dir -and -not (Test-Path -LiteralPath $dir -PathType Container)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + + # Same atomic write the blocklist gets: a half-written allowlist would silently under-subtract. + $tmp = $Path + '.tmp' + [IO.File]::WriteAllLines($tmp, $Lines, [System.Text.UTF8Encoding]::new($false)) + [IO.File]::Move($tmp, $Path, $true) +} + +$OperatorTemplate = @' +# ModernUO blocklist allowlist -- every entry here is SUBTRACTED from the generated blocklist. +# +# This file is yours. Export-IpBlocklist.ps1 creates it once and never rewrites it, so anything you add +# survives every regeneration. +# +# One entry per line: a bare IPv4 address (1.2.3.4) or a CIDR (1.2.3.0/24). Lines starting with '#' or ';' +# are comments. Order does not matter. Re-run Export-IpBlocklist.ps1 to apply changes -- editing this file +# bypasses the -MinInterval cooldown, so no -Force is needed. +# +# Removal is range-correct: an address listed here is removed even when a feed published it as part of a +# larger CIDR -- that CIDR is split around the hole rather than dropped wholesale or silently ignored. +# +# Network carve-outs live in their own ip-allowlist-.txt beside this one (see -AddCarveout), so they +# can be regenerated or copied between shards without touching anything you put here. +# +# Put player/staff exemptions below, one per line, e.g.: +# 203.0.113.42 # shard owner, listed via a shared upstream address +'@ + +# Carve-out files carry their own `asn=` marker, so -RefreshCarveouts can rebuild whatever an admin created +# without this script keeping a list of anyone's networks. +function Get-CarveoutHeader { + param([string]$Name, [int]$CarveoutAsn) + + @( + ("# {0} carve-out (asn={1}) -- subtracted from the generated blocklist." -f $Name, $CarveoutAsn) + "#" + "# Reputation feeds list shared consumer address space constantly, so a hit inside a CGNAT network" + "# says little about the player currently behind it. Abusive hosts here are still caught on BEHAVIOR." + "#" + "# GENERATED DATA -- safe to regenerate, diff, or copy to another shard. Blank the file (keep the" + "# file) to reputation-block this network again; delete it to stop carving it out entirely." + "#" + "# Refresh with: .\Export-IpBlocklist.ps1 -RefreshCarveouts" + ) +} + +# Fetches a network's currently ANNOUNCED prefixes and collapses them. Routing data, not a registry: +# ownership records disagree with what is actually announced, and registry queries cap their result sets. +function Get-CarveoutPrefixes { + param([int]$CarveoutAsn) + + $url = "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS$CarveoutAsn" + $json = Get-Url -Url $url -Label ("AS{0} prefixes" -f $CarveoutAsn) | ConvertFrom-Json + + $v4 = @($json.data.prefixes.prefix | Where-Object { $_ -and $_ -notmatch ':' }) + if (-not $v4) { throw "AS$CarveoutAsn announced no IPv4 prefixes -- refusing to overwrite the carve-out." } + + [BlocklistExporter]::CollapsePrefixes(($v4 -join "`n")) +} + +# Reads the `asn=` marker back out of a carve-out file. Anything without one is a hand-written allowlist and +# is left alone by -RefreshCarveouts. +function Get-CarveoutAsn { + param([string]$Path) + + foreach ($line in (Get-Content -LiteralPath $Path -TotalCount 5 -ErrorAction SilentlyContinue)) { + if ($line -match 'asn=(\d+)') { return [int]$Matches[1] } + } + + return 0 +} + +# The operator's own list is the one file this script will create unprompted; carve-outs are opt-in. +if (-not $allowExplicit) { + $operatorPath = Join-Path $ConfigDir 'ip-allowlist.txt' + if (-not (Test-Path -LiteralPath $operatorPath -PathType Leaf)) { + Write-AllowlistFile -Path $operatorPath -Lines @($OperatorTemplate) + Write-Host ("Created allowlist at {0} (add player/staff exemptions here)." -f $operatorPath) + $AllowPaths = @($operatorPath) + $AllowPaths + } +} +else { + $AllowPaths = @($AllowPaths | ForEach-Object { + if (Test-Path -LiteralPath $_ -PathType Leaf) { return $_ } + Write-Warning ("Allowlist '{0}' does not exist -- nothing will be subtracted from it. Check the path." -f $_) + }) +} + +# --------------------------------------------------------------------------------------------------------- +# Cooldown gate. Runs BEFORE anything is downloaded: the whole point is that a misconfigured scheduler or a +# retry loop cannot spam the upstream feeds. State lives in the output file itself (`generated=` header, +# mtime as fallback), so it is correct across reboots, machines and hand-runs with no sidecar state file. +# --------------------------------------------------------------------------------------------------------- +function ConvertTo-Duration { + param([string]$Text) + if ([string]::IsNullOrWhiteSpace($Text)) { return [TimeSpan]::Zero } + $t = $Text.Trim().ToLowerInvariant() + $unit = $t[$t.Length - 1] + $numText = if ($unit -match '[0-9.]') { $t } else { $t.Substring(0, $t.Length - 1) } + $n = 0.0 + # InvariantCulture is not optional here: under a comma-decimal locale (de-DE, fr-FR, ...) the + # current-culture parse reads '2.5' as 25 -- it treats '.' as a group separator and SUCCEEDS, so + # `-MinInterval 2.5h` would silently become a 25 hour cooldown instead of failing loudly. + if (-not [double]::TryParse($numText, [Globalization.NumberStyles]::Float, + [Globalization.CultureInfo]::InvariantCulture, [ref]$n)) { + throw "Could not parse duration '$Text' (try 90s, 45m, 2h, 2.5h, 1d)." + } + switch ($unit) { + 's' { return [TimeSpan]::FromSeconds($n) } + 'm' { return [TimeSpan]::FromMinutes($n) } + 'h' { return [TimeSpan]::FromHours($n) } + 'd' { return [TimeSpan]::FromDays($n) } + default { return [TimeSpan]::FromHours($n) } # bare number == hours + } +} + +# Age of the list already on disk, or $null when there is nothing usable to age. +function Get-BlocklistAge { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + + # Prefer the header we wrote: it describes the data, not the file, so copying/restoring the file + # cannot make a stale list look fresh (or a fresh one look stale). + try { + $first = Get-Content -LiteralPath $Path -TotalCount 1 -ErrorAction Stop + if ($first -and $first.StartsWith('#')) { + foreach ($tok in $first.Split(' ', [StringSplitOptions]::RemoveEmptyEntries)) { + if ($tok.StartsWith('generated=', [StringComparison]::Ordinal)) { + $stamp = [DateTime]::MinValue + $styles = [Globalization.DateTimeStyles]::AdjustToUniversal -bor [Globalization.DateTimeStyles]::AssumeUniversal + if ([DateTime]::TryParse($tok.Substring(10), [Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$stamp)) { + return @{ Age = ([DateTime]::UtcNow - $stamp); Stamp = $tok.Substring(10); Source = 'header' } + } + } + } + } + } + catch { } + + # Hand-maintained or truncated file: fall back to the filesystem timestamp. + try { + $w = (Get-Item -LiteralPath $Path -ErrorAction Stop).LastWriteTimeUtc + return @{ Age = ([DateTime]::UtcNow - $w) + Stamp = $w.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) + Source = 'mtime' } + } + catch { return $null } +} + +$minAge = ConvertTo-Duration $MinInterval +# Asking for carve-out data implies -Force: waiting out the cooldown and leaving the old data in place would +# be the wrong answer. +if (-not $Force -and -not $RefreshCarveouts -and -not $AddCarveout -and $minAge -gt [TimeSpan]::Zero) { + $existing = Get-BlocklistAge -Path $OutFile + if ($existing) { + # A negative age means the stamp is in the future (clock skew, or a file from another host). Treat it + # as fresh: refusing to run is the recoverable failure, hammering the feeds on every tick is not. + if ($existing.Age -lt $minAge) { + # An allowlist edited since the list was built is the one case where waiting out the cooldown is + # the wrong answer: the operator is unblocking someone, and "nothing happened" is indistinguishable + # from the allowlist not working. Cheap to honour -- it can only ever shrink the output. + $changedAllow = $null + $builtAt = [DateTime]::UtcNow - $existing.Age + foreach ($p in $AllowPaths) { + try { + if ((Get-Item -LiteralPath $p -ErrorAction Stop).LastWriteTimeUtc -gt $builtAt) { + $changedAllow = $p + break + } + } + catch { } + } + + if ($changedAllow) { + Write-Host ("Allowlist {0} changed since the blocklist was built; regenerating despite -MinInterval {1}." -f ` + $changedAllow, $MinInterval) + } + else { + $agoText = if ($existing.Age -lt [TimeSpan]::Zero) { 'in the future -- check the clock' } else { ("{0:N1}h ago" -f $existing.Age.TotalHours) } + Write-Host ("Blocklist at {0} was generated {1} ({2}={3}); newer than -MinInterval {4}." -f ` + $OutFile, $agoText, $existing.Source, $existing.Stamp, $MinInterval) + Write-Host "Nothing downloaded. Pass -Force to regenerate now, or lower -MinInterval." + return + } + } + } +} + +# --------------------------------------------------------------------------------------------------------- +# Compiled hot loop. Interpreted PowerShell chokes on bitwire's ~4M lines; this parses + validates + bogon- +# filters + de-dupes in one compiled pass, and writes the final file directly (no 4M-element PS pipelines). +# Deliberately plain C#: no LINQ, no generics beyond HashSet, nothing that would slow the hot loop. +# --------------------------------------------------------------------------------------------------------- +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +public static class BlocklistExporter +{ + static bool TryParseIPv4(string s, int start, int len, out uint val) + { + val = 0; + uint acc = 0; int octet = 0, dots = 0, digits = 0, end = start + len; + for (int i = start; i < end; i++) + { + char c = s[i]; + if (c == '.') + { + if (digits == 0 || octet > 255) return false; + acc = (acc << 8) | (uint)octet; dots++; octet = 0; digits = 0; + } + else if (c >= '0' && c <= '9') + { + octet = octet * 10 + (c - '0'); if (++digits > 3) return false; + } + else return false; + } + if (dots != 3 || digits == 0 || octet > 255) return false; + val = (acc << 8) | (uint)octet; + return true; + } + + static bool IsBogon(uint start, uint end, uint[] bs, uint[] be) + { + for (int i = 0; i < bs.Length; i++) + if (start <= be[i] && end >= bs[i]) return true; + return false; + } + + // Parse one feed's text; add bare IPs to `singles`, CIDRs to `cidrs`. Returns count newly added. + public static int AddContent(string content, HashSet singles, HashSet cidrs, uint[] bs, uint[] be) + { + int added = 0, n = content.Length, i = 0; + while (i < n) + { + int eol = content.IndexOf('\n', i); + int lineEnd = (eol < 0) ? n : eol; + int a = i, b = lineEnd; + while (a < b && (content[a] == ' ' || content[a] == '\t' || content[a] == '\r')) a++; + while (b > a && (content[b - 1] == ' ' || content[b - 1] == '\t' || content[b - 1] == '\r')) b--; + i = (eol < 0) ? n : eol + 1; + if (a >= b) continue; + char first = content[a]; + if (first == '#' || first == ';') continue; + + // Feeds vary: some are bare IPs, some are CSV/whitespace records with the IP first. + int t = a; + while (t < b) + { + char c = content[t]; + if (c == ' ' || c == '\t' || c == ',' || c == ';') break; + t++; + } + int slash = -1; + for (int k = a; k < t; k++) { if (content[k] == '/') { slash = k; break; } } + + if (slash >= 0) + { + uint ip; + if (!TryParseIPv4(content, a, slash - a, out ip)) continue; + int bits = 0, bd = 0; + for (int k = slash + 1; k < t; k++) + { + char c = content[k]; + if (c < '0' || c > '9') { bd = -1; break; } + bits = bits * 10 + (c - '0'); bd++; + } + if (bd <= 0 || bits > 32) continue; + ulong size = (bits == 0) ? 0xFFFFFFFFUL : ((1UL << (32 - bits)) - 1UL); + ulong endAddr = (ulong)ip + size; if (endAddr > 0xFFFFFFFFUL) endAddr = 0xFFFFFFFFUL; + if (IsBogon(ip, (uint)endAddr, bs, be)) continue; + if (cidrs.Add(content.Substring(a, t - a))) added++; + } + else + { + uint ip; + if (!TryParseIPv4(content, a, t - a, out ip)) continue; + if (IsBogon(ip, ip, bs, be)) continue; + if (singles.Add(ip)) added++; + } + } + return added; + } + + // --------------------------------------------------------------------------------------------------- + // Allowlist subtraction. Allow entries become sorted, merged [start,end] ranges once; the blocklist is + // then filtered against them. The interesting case is a blocked CIDR that only PARTIALLY overlaps an + // allow range -- dropping it whole would unblock far more than asked, keeping it whole would ignore the + // exemption, so it is split into the surviving pieces and re-emitted as minimal CIDRs. + // --------------------------------------------------------------------------------------------------- + public sealed class RangeSet + { + public uint[] Start; + public uint[] End; + public int Count; + } + + public sealed class AllowResult + { + public int SinglesRemoved; + public int CidrsDropped; + public int CidrsSplit; + public int EntriesAdded; + } + + // Parses "a.b.c.d/p" to an inclusive range. The base is masked to the prefix, so a sloppy 1.2.3.5/24 + // means the whole 1.2.3.0/24 -- the standard reading, and the safe direction for an exemption. + static bool TryCidrRange(string c, out ulong lo, out ulong hi) + { + lo = 0; hi = 0; + int slash = c.IndexOf('/'); + if (slash <= 0) return false; + uint ip; + if (!TryParseIPv4(c, 0, slash, out ip)) return false; + int bits = 0, bd = 0; + for (int i = slash + 1; i < c.Length; i++) + { + char ch = c[i]; + if (ch < '0' || ch > '9') { bd = -1; break; } + bits = bits * 10 + (ch - '0'); bd++; + } + if (bd <= 0 || bits > 32) return false; + // 1u << 32 is undefined in C# (the shift count is masked to 5 bits), so /0 is special-cased. + uint mask = (bits == 0) ? 0u : ~((uint)((1UL << (32 - bits)) - 1UL)); + ulong size = (bits == 0) ? 0x100000000UL : (1UL << (32 - bits)); + lo = ip & mask; + hi = lo + size - 1UL; + return true; + } + + public static RangeSet BuildRanges(HashSet singles, HashSet cidrs) + { + uint[] s = new uint[singles.Count + cidrs.Count]; + uint[] e = new uint[s.Length]; + int k = 0; + + foreach (uint v in singles) { s[k] = v; e[k] = v; k++; } + foreach (string c in cidrs) + { + ulong lo, hi; + if (!TryCidrRange(c, out lo, out hi)) continue; + s[k] = (uint)lo; + e[k] = (uint)(hi > 0xFFFFFFFFUL ? 0xFFFFFFFFUL : hi); + k++; + } + + Array.Resize(ref s, k); + Array.Resize(ref e, k); + Array.Sort(s, e); + + // Coalesce overlapping AND adjacent ranges so the lookups below can assume disjoint, ordered spans. + int w = 0; + for (int i = 0; i < k; i++) + { + if (w > 0 && (ulong)s[i] <= (ulong)e[w - 1] + 1UL) + { + if (e[i] > e[w - 1]) e[w - 1] = e[i]; + } + else + { + s[w] = s[i]; e[w] = e[i]; w++; + } + } + + return new RangeSet { Start = s, End = e, Count = w }; + } + + // Index of the first range whose End >= v (ranges are disjoint and sorted, so End is sorted too). + static int FirstEndAtLeast(uint[] re, int n, uint v) + { + int lo = 0, hi = n; + while (lo < hi) + { + int mid = (int)(((uint)lo + (uint)hi) >> 1); + if (re[mid] < v) lo = mid + 1; else hi = mid; + } + return lo; + } + + static bool Covered(uint[] rs, uint[] re, int n, uint v) + { + int i = FirstEndAtLeast(re, n, v); + return i < n && rs[i] <= v; + } + + static string FormatCidr(uint ip, int bits) + { + char[] buf = new char[19]; + int p = 0; + p = WriteOctet(buf, p, (ip >> 24) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (ip >> 16) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (ip >> 8) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, ip & 255); buf[p++] = '/'; + p = WriteOctet(buf, p, (uint)bits); + return new string(buf, 0, p); + } + + // Writes [lo,hi] as the minimal set of aligned CIDR blocks. A /32 goes back to the singles set so the + // output keeps the file's convention of bare addresses for single hosts. + static void Emit(ulong lo, ulong hi, HashSet singles, HashSet cidrs, AllowResult r) + { + while (lo <= hi) + { + int bits = 32; + while (bits > 0) + { + ulong size = 1UL << (32 - (bits - 1)); + if ((lo % size) != 0UL) break; + if (lo + size - 1UL > hi) break; + bits--; + } + + if (bits == 32) + { + if (singles.Add((uint)lo)) r.EntriesAdded++; + } + else if (cidrs.Add(FormatCidr((uint)lo, bits))) + { + r.EntriesAdded++; + } + + lo += 1UL << (32 - bits); + } + } + + public static AllowResult ApplyAllowlist(HashSet singles, HashSet cidrs, RangeSet allow) + { + var r = new AllowResult(); + if (allow == null || allow.Count == 0) return r; + + uint[] rs = allow.Start, re = allow.End; + int n = allow.Count; + + // Singles first: the CIDR pass below can add new singles, and those are outside the allow ranges by + // construction, so re-testing them would be wasted work. + uint[] sarr = new uint[singles.Count]; + singles.CopyTo(sarr); + for (int i = 0; i < sarr.Length; i++) + { + if (Covered(rs, re, n, sarr[i]) && singles.Remove(sarr[i])) r.SinglesRemoved++; + } + + string[] carr = new string[cidrs.Count]; + cidrs.CopyTo(carr); + cidrs.Clear(); + + for (int i = 0; i < carr.Length; i++) + { + string c = carr[i]; + ulong lo, hi; + + // Unparseable entries are kept verbatim rather than dropped: this pass exists to subtract, and + // silently discarding something it could not read would weaken the list. + if (!TryCidrRange(c, out lo, out hi)) { cidrs.Add(c); continue; } + if (hi > 0xFFFFFFFFUL) hi = 0xFFFFFFFFUL; + + int idx = FirstEndAtLeast(re, n, (uint)lo); + if (idx >= n || (ulong)rs[idx] > hi) { cidrs.Add(c); continue; } // no overlap: the common case + + ulong cursor = lo; + int before = r.EntriesAdded; + for (int j = idx; j < n && (ulong)rs[j] <= hi; j++) + { + if ((ulong)rs[j] > cursor) Emit(cursor, (ulong)rs[j] - 1UL, singles, cidrs, r); + ulong next = (ulong)re[j] + 1UL; + if (next > cursor) cursor = next; + if (cursor > hi) break; + } + if (cursor <= hi) Emit(cursor, hi, singles, cidrs, r); + + if (r.EntriesAdded == before) r.CidrsDropped++; else r.CidrsSplit++; + } + + return r; + } + + static string FormatIp(uint ip) + { + char[] buf = new char[16]; + int p = 0; + p = WriteOctet(buf, p, (ip >> 24) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (ip >> 16) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (ip >> 8) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, ip & 255); + return new string(buf, 0, p); + } + + // Collapses a prefix list into the minimal equivalent set, in ascending order. Used by + // -RefreshCarveouts: routing data publishes thousands of overlapping announcements. + public static string[] CollapsePrefixes(string content) + { + uint[] noBogon = new uint[0]; + var singles = new HashSet(); + var cidrs = new HashSet(); + AddContent(content, singles, cidrs, noBogon, noBogon); + + var ranges = BuildRanges(singles, cidrs); + var result = new List(); + + for (int i = 0; i < ranges.Count; i++) + { + ulong lo = ranges.Start[i], hi = ranges.End[i]; + + while (lo <= hi) + { + int bits = 32; + while (bits > 0) + { + ulong size = 1UL << (32 - (bits - 1)); + if ((lo % size) != 0UL) break; + if (lo + size - 1UL > hi) break; + bits--; + } + + result.Add(bits == 32 ? FormatIp((uint)lo) : FormatCidr((uint)lo, bits)); + lo += 1UL << (32 - bits); + } + } + + return result.ToArray(); + } + + static int WriteOctet(char[] buf, int pos, uint v) + { + if (v >= 100) { buf[pos++] = (char)('0' + v / 100); buf[pos++] = (char)('0' + (v / 10) % 10); } + else if (v >= 10) { buf[pos++] = (char)('0' + v / 10); } + buf[pos++] = (char)('0' + v % 10); + return pos; + } + + // Writes the whole blocklist in one streamed pass so we never materialize millions of strings. + // Header first (the shard's reload detector requires it), then singles, then CIDRs. LF line endings. + public static void Write(string path, string header, HashSet singles, HashSet cidrs) + { + using (var w = new StreamWriter(path, false, new UTF8Encoding(false), 1 << 20)) + { + w.Write(header); w.Write('\n'); + + char[] buf = new char[20]; + foreach (uint v in singles) + { + int p = 0; + p = WriteOctet(buf, p, (v >> 24) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (v >> 16) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, (v >> 8) & 255); buf[p++] = '.'; + p = WriteOctet(buf, p, v & 255); buf[p++] = '\n'; + w.Write(buf, 0, p); + } + + foreach (string c in cidrs) { w.Write(c); w.Write('\n'); } + } + } +} +'@ + +# --------------------------------------------------------------------------------------------------------- +# Feed set -- thin, non-overlapping. See .DESCRIPTION for why each is kept and what was dropped as redundant. +# romainmarcoux's "full" set is sharded; only aa..ad carry data today (ae.. are empty placeholders). +# A 404/empty shard is skipped, so extend this list if upstream grows the shard count. +# --------------------------------------------------------------------------------------------------------- +$rmBase = 'https://raw.githubusercontent.com/romainmarcoux/malicious-ip/main/full-300k-' +$rmShards = @('aa','ab','ac','ad') | ForEach-Object { $rmBase + $_ + '.txt' } + +$AllFeeds = @( + [pscustomobject]@{ Name = 'bitwire-it'; Urls = @('https://raw.githubusercontent.com/bitwire-it/ipblocklist/main/inbound.txt') } + [pscustomobject]@{ Name = 'romainmarcoux'; Urls = $rmShards } + [pscustomobject]@{ Name = 'sentinel-turris';Urls = @('https://view.sentinel.turris.cz/greylist-data/greylist-latest.csv') } + [pscustomobject]@{ Name = 'firehol-level1'; Urls = @('https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset') } +) + +# Anonymizer / relay lists subtracted only when -ExcludeAnonymizers is set (Tor exits, open proxies, VPN tunnels). +$AnonFeeds = @( + 'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/tor_exits.ipset' + 'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/sslproxies_7d.ipset' + 'https://raw.githubusercontent.com/borestad/firehol-mirror/refs/heads/main/socks_proxy_7d.ipset' + 'https://raw.githubusercontent.com/ShadowWhisperer/IPs/master/Lists/Tunnels' +) + +if ($Feeds) { + $AllFeeds = $AllFeeds | Where-Object { $Feeds -contains $_.Name } + if (-not $AllFeeds) { throw "No feeds matched -Feeds." } +} + +# --------------------------------------------------------------------------------------------------------- +# Reserved / bogon ranges -- never valid attacker SOURCE IPs; always filtered. Built once as uint32 arrays. +# --------------------------------------------------------------------------------------------------------- +function ConvertTo-IPv4UInt { + param([string]$s) + $a = $s.Split('.') + if ($a.Length -ne 4) { return $null } + $v = [uint32]0 + foreach ($o in $a) { + $n = 0 + if (-not [int]::TryParse($o, [ref]$n) -or $n -lt 0 -or $n -gt 255) { return $null } + $v = ($v -shl 8) -bor [uint32]$n + } + return $v +} + +$bogonCidrs = '0.0.0.0/8','10.0.0.0/8','100.64.0.0/10','127.0.0.0/8','169.254.0.0/16','172.16.0.0/12', + '192.0.0.0/24','192.0.2.0/24','192.168.0.0/16','198.18.0.0/15','198.51.100.0/24', + '203.0.113.0/24','224.0.0.0/3' # 224/3 covers multicast + reserved + 255.255.255.255 +$bogStart = [System.Collections.Generic.List[uint32]]::new() +$bogEnd = [System.Collections.Generic.List[uint32]]::new() +foreach ($c in $bogonCidrs) { + $p = $c.Split('/'); $base = ConvertTo-IPv4UInt $p[0]; $bits = [int]$p[1] + $size = [uint32]([Math]::Pow(2, 32 - $bits)) + $bogStart.Add($base); $bogEnd.Add([uint32]($base + $size - 1)) +} +$bogStart = $bogStart.ToArray(); $bogEnd = $bogEnd.ToArray() + +# --------------------------------------------------------------------------------------------------------- +# Streaming download with a live progress bar (Write-Progress) so large feeds show real byte progress. +# --------------------------------------------------------------------------------------------------------- +function Get-Url { + param([string]$Url, [string]$Label) + $req = [System.Net.HttpWebRequest]::Create($Url) + $req.UserAgent = $UA; $req.Timeout = 120000; $req.ReadWriteTimeout = 120000 + $resp = $req.GetResponse() + try { + $total = $resp.ContentLength + $stream = $resp.GetResponseStream() + $ms = New-Object System.IO.MemoryStream + $buf = New-Object byte[] (1MB) + $read = 0; $lastReport = 0 + while (($n = $stream.Read($buf, 0, $buf.Length)) -gt 0) { + $ms.Write($buf, 0, $n); $read += $n + if ($read - $lastReport -ge 2MB) { + $lastReport = $read + if ($total -gt 0) { + Write-Progress -Activity ("Downloading {0}" -f $Label) -PercentComplete ([int](100 * $read / $total)) ` + -Status ("{0:N1} / {1:N1} MB" -f ($read / 1MB), ($total / 1MB)) + } else { + Write-Progress -Activity ("Downloading {0}" -f $Label) -Status ("{0:N1} MB" -f ($read / 1MB)) + } + } + } + Write-Progress -Activity ("Downloading {0}" -f $Label) -Completed + return [System.Text.Encoding]::UTF8.GetString($ms.ToArray()) + } + finally { $resp.Close() } +} + +# --------------------------------------------------------------------------------------------------------- +# Refresh carve-out data from routing announcements. Runs here because it needs Get-Url and the compiled +# collapser. A failure leaves the existing file alone rather than truncating a working carve-out. +# --------------------------------------------------------------------------------------------------------- +if ($AddCarveout) { + $path = Join-Path $ConfigDir ("ip-allowlist-{0}.txt" -f $AddCarveout) + $prefixes = Get-CarveoutPrefixes -CarveoutAsn $Asn + + Write-AllowlistFile -Path $path -Lines (@(Get-CarveoutHeader -Name $AddCarveout -CarveoutAsn $Asn) + $prefixes) + Write-Host ("Created {0} carve-out from AS{1}: {2} prefixes -> {3}" -f $AddCarveout, $Asn, @($prefixes).Count, $path) + + if ($AllowPaths -notcontains $path) { $AllowPaths += $path } +} + +if ($RefreshCarveouts) { + foreach ($path in $AllowPaths) { + $carveoutAsn = Get-CarveoutAsn -Path $path + if ($carveoutAsn -le 0) { continue } # hand-written allowlist, not ours to rewrite + + try { + $name = [IO.Path]::GetFileNameWithoutExtension($path) -replace '^ip-allowlist-', '' + $prefixes = Get-CarveoutPrefixes -CarveoutAsn $carveoutAsn + Write-AllowlistFile -Path $path -Lines (@(Get-CarveoutHeader -Name $name -CarveoutAsn $carveoutAsn) + $prefixes) + Write-Host ("Refreshed {0} carve-out from AS{1}: {2} prefixes" -f $name, $carveoutAsn, @($prefixes).Count) + } + catch { + Write-Warning ("AS{0}: refresh failed ({1}) -- keeping the existing carve-out" -f $carveoutAsn, $_.Exception.Message) + } + } +} + +# --------------------------------------------------------------------------------------------------------- +# Collect every kept feed into ONE global set, timing each phase. +# --------------------------------------------------------------------------------------------------------- +$singles = [System.Collections.Generic.HashSet[uint32]]::new() +$cidrs = [System.Collections.Generic.HashSet[string]]::new() +$feedCount = 0 + +foreach ($feed in $AllFeeds) { + $before = $singles.Count + $cidrs.Count + $ok = $false + foreach ($url in $feed.Urls) { + try { + $dlSw = [System.Diagnostics.Stopwatch]::StartNew() + $content = Get-Url -Url $url -Label $feed.Name + $dlSw.Stop() + $mb = [Math]::Round($content.Length / 1MB, 1) + + $pSw = [System.Diagnostics.Stopwatch]::StartNew() + [void][BlocklistExporter]::AddContent($content, $singles, $cidrs, $bogStart, $bogEnd) + $pSw.Stop() + Write-Host (" [dl {0,6:N1}s / parse {1,5:N1}s] {2}" -f $dlSw.Elapsed.TotalSeconds, $pSw.Elapsed.TotalSeconds, ("{0} ({1} MB)" -f $feed.Name, $mb)) + $ok = $true + } + catch { Write-Warning ("{0}: {1} -- skipping shard ({2})" -f $feed.Name, $url, $_.Exception.Message) } + } + if ($ok) { + $feedCount++ + $delta = ($singles.Count + $cidrs.Count) - $before + Write-Host ("{0,-16} +{1,8} new (running total {2} ip / {3} cidr)`n" -f $feed.Name, $delta, $singles.Count, $cidrs.Count) + } + else { Write-Warning ("{0}: all sources failed -- skipping" -f $feed.Name) } +} + +# --------------------------------------------------------------------------------------------------------- +# Subtraction pass: the allowlist file, plus the anonymizer feeds when -ExcludeAnonymizers is set. Both are +# "never block these", so they share one set and one range-correct removal -- which is also the fix for the +# old anonymizer path, where CIDR entries were parsed and then never actually subtracted. +# +# Bogon filtering is deliberately NOT applied here: it exists to keep junk OUT of the blocklist, and running +# it over subtractive input would quietly discard exemptions instead (e.g. a shard exempting its own LAN). +# --------------------------------------------------------------------------------------------------------- +$allowSingles = [System.Collections.Generic.HashSet[uint32]]::new() +$allowCidrs = [System.Collections.Generic.HashSet[string]]::new() +$noBogon = [uint32[]]::new(0) + +foreach ($p in $AllowPaths) { + try { + $allowText = Get-Content -LiteralPath $p -Raw -ErrorAction Stop + if (-not $allowText) { continue } + + $before = $allowSingles.Count + $allowCidrs.Count + [void][BlocklistExporter]::AddContent($allowText, $allowSingles, $allowCidrs, $noBogon, $noBogon) + Write-Host (" allowlist {0,-28} +{1} entr(ies)" -f (Split-Path -Leaf $p), (($allowSingles.Count + $allowCidrs.Count) - $before)) + } + catch { Write-Warning ("Could not read allowlist {0}: {1}" -f $p, $_.Exception.Message) } +} + +if ($ExcludeAnonymizers) { + foreach ($url in $AnonFeeds) { + try { [void][BlocklistExporter]::AddContent((Get-Url -Url $url -Label 'anonymizers'), $allowSingles, $allowCidrs, $noBogon, $noBogon) } + catch { Write-Warning ("anonymizer list {0}: {1}" -f $url, $_.Exception.Message) } + } +} + +$allowCount = $allowSingles.Count + $allowCidrs.Count +if ($allowCount -gt 0) { + $aSw = [System.Diagnostics.Stopwatch]::StartNew() + $ranges = [BlocklistExporter]::BuildRanges($allowSingles, $allowCidrs) + $res = [BlocklistExporter]::ApplyAllowlist($singles, $cidrs, $ranges) + $aSw.Stop() + + Write-Host ("Allowlist: {0} entr(ies) -> {1} ranges; removed {2} IPs, dropped {3} CIDRs, split {4} into {5} ({6:N1}s)" -f ` + $allowCount, $ranges.Count, $res.SinglesRemoved, $res.CidrsDropped, $res.CidrsSplit, $res.EntriesAdded, $aSw.Elapsed.TotalSeconds) +} + +$total = $singles.Count + $cidrs.Count +Write-Host ("Merged {0} feed(s): {1} unique single IPs + {2} unique CIDRs (bogon-filtered) in {3:N1}s." -f ` + $feedCount, $singles.Count, $cidrs.Count, $totalSw.Elapsed.TotalSeconds) + +if ($DryRun) { + Write-Host ("DRY RUN: nothing written (would have written {0} entries to {1})." -f $total, $OutFile) + return +} + +# A partial feed outage must not silently shrink the shard's blocklist to nothing; keep the last good file. +if ($total -eq 0) { throw "No entries parsed -- refusing to overwrite '$OutFile' with an empty list." } + +# --------------------------------------------------------------------------------------------------------- +# Write to a .tmp sibling and swap it into place, so the shard (which reads the whole file on a change) +# never observes a half-written list. One rename does it whether or not a list is already there. +# --------------------------------------------------------------------------------------------------------- +$outDir = Split-Path -Parent $OutFile +if ($outDir -and -not (Test-Path -LiteralPath $outDir -PathType Container)) { + New-Item -ItemType Directory -Path $outDir -Force | Out-Null +} + +# InvariantCulture: ':' is the culture-defined time separator in a custom format string, and the +# header is a machine-read marker the shard compares verbatim. +$generated = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) +# The shard's header reader is token-based and ignores tokens it does not know, so `allow=` is additive -- +# it is here so an operator can tell from the file alone whether a carve-out was in effect when it was built. +$header = "# modernuo-blocklist generated=$generated count=$total ipv4=$($singles.Count) cidr=$($cidrs.Count) feeds=$feedCount allow=$allowCount" + +$tmp = $OutFile + '.tmp' +$wSw = [System.Diagnostics.Stopwatch]::StartNew() +try { + [BlocklistExporter]::Write($tmp, $header, $singles, $cidrs) + # One atomic rename over the destination on every platform: MoveFileEx REPLACE_EXISTING on + # Windows, rename(2) on Linux and macOS. + [IO.File]::Move($tmp, $OutFile, $true) +} +finally { + # Never leave a partial .tmp next to a live blocklist for the next run to trip over. + if (Test-Path -LiteralPath $tmp -PathType Leaf) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue } +} +$wSw.Stop() + +$sizeMb = [Math]::Round((Get-Item -LiteralPath $OutFile).Length / 1MB, 1) +Write-Host ("`nWrote {0} entries ({1} MB) to {2} in {3:N1}s (total {4:N1}s). generated={5}" -f ` + $total, $sizeMb, $OutFile, $wSw.Elapsed.TotalSeconds, $totalSw.Elapsed.TotalSeconds, $generated) +Write-Host "The shard picks this up on its next reloadInterval poll; no restart needed."