Compare commits

..

2 commits

Author SHA1 Message Date
Kamron Batman
a4104d0768
docs(website): drop zstd from the install requirements
ZstdNet bundles libzstd for every RID, so nothing needs a system zstd.
Verified: the 15 ManagedArchive round-trip tests pass in a container
with no zstd package installed, and the build deploys
runtimes/<rid>/native/libzstd.* for linux-x64, linux-arm64, osx-x64,
osx-arm64 and win. The apt/dnf "zstd" package is the CLI tool, which
nothing shells out to.

Also aligns the Fedora line to libicu rather than libicu-devel, matching
the README and NativeLibraryChecker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:36:03 -07:00
Kamron Batman
4dacd498e3
feat: Adds website 2026-08-06 21:34:53 -07:00
404 changed files with 50318 additions and 12186 deletions

View file

@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"modernuoschemagenerator": { "modernuoschemagenerator": {
"version": "4.0.0", "version": "2.14.3",
"commands": [ "commands": [
"ModernUOSchemaGenerator" "ModernUOSchemaGenerator"
] ]

View file

@ -46,7 +46,7 @@ jobs:
- name: Install Prerequisites - name: Install Prerequisites
run: | run: |
brew update brew update
brew install icu4c libdeflate argon2 brew install icu4c libdeflate zstd argon2
- name: Set Library Path - name: Set Library Path
run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV
- name: Build - name: Build
@ -124,36 +124,12 @@ jobs:
dnf config-manager --set-enabled crb dnf config-manager --set-enabled crb
dnf install -y epel-release dnf install -y epel-release
if: ${{ matrix.epel }} 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 - name: Install Prerequisites using dnf
run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate libargon2 tzdata run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel
if: ${{ matrix.packageManager == 'dnf' }} 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 - name: Install Prerequisites using apt
run: apt-get update -y && apt-get install -y curl '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata
if: ${{ matrix.packageManager == 'apt' }} 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 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 # avoid shallow clone so nbgv can do its work. fetch-depth: 0 # avoid shallow clone so nbgv can do its work.

36
.github/workflows/update-docs.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Deploy Docs
on:
push:
branches: [website]
paths:
- 'website/**'
- '.github/workflows/update-docs.yml'
workflow_dispatch:
jobs:
deploy-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build packets documentation
shell: pwsh
run: ./website/tools/build-packets.ps1 -OutputPath ./website/static/packets.html
- name: Install dependencies
working-directory: website
run: npm ci
- name: Build site
working-directory: website
run: npm run build
- name: Deploy to GitHub Pages
uses: JamesIves/github-pages-deploy-action@v4
with:
folder: ./website/build
branch: gh-pages
clean: true
clean-exclude: |
.nojekyll

3
.gitignore vendored
View file

@ -1,5 +1,4 @@
# Distribution Files # Distribution Files
/Distribution/Data/Files
/Distribution/Logger /Distribution/Logger
/Distribution/Logger.* /Distribution/Logger.*
/Distribution/ModernUO /Distribution/ModernUO
@ -15,7 +14,6 @@
/Distribution/Configuration/blocklist.json /Distribution/Configuration/blocklist.json
/Distribution/Configuration/crowdsec.json /Distribution/Configuration/crowdsec.json
/Distribution/Configuration/expansion.json /Distribution/Configuration/expansion.json
/Distribution/Configuration/firewall.json
/Distribution/Configuration/ip-allowlist*.txt /Distribution/Configuration/ip-allowlist*.txt
/Distribution/Configuration/ip-allowlist*.txt.tmp /Distribution/Configuration/ip-allowlist*.txt.tmp
/Distribution/Configuration/ip-blocklist.txt /Distribution/Configuration/ip-blocklist.txt
@ -27,7 +25,6 @@
/Distribution/Configuration/email-settings.json /Distribution/Configuration/email-settings.json
/Distribution/Configuration/throttles.json /Distribution/Configuration/throttles.json
/Distribution/Configuration/tot.json /Distribution/Configuration/tot.json
/Distribution/Data/Pathfinding
/Distribution/Logs /Distribution/Logs
/Distribution/Archives /Distribution/Archives
/Distribution/Backups /Distribution/Backups

View file

@ -18,8 +18,8 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()` 6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()`
7. **`STArrayPool<T>.Shared`** not `ArrayPool<T>.Shared` — single-threaded optimized, no locks 7. **`STArrayPool<T>.Shared`** not `ArrayPool<T>.Shared` — single-threaded optimized, no locks
8. **`PooledRefList<T>`** not `new List<T>()` on hot paths — zero GC pressure, stack-allocated ref struct 8. **`PooledRefList<T>`** not `new List<T>()` 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`). 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` 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()` 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 12 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` 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). 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(() => …)`. 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 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 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` 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`
@ -29,7 +29,6 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
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` 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 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` 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 ## Dev-Docs Reference
@ -46,11 +45,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
| Commands & targeting | `dev-docs/commands-targeting.md` | | Commands & targeting | `dev-docs/commands-targeting.md` |
| Event system | `dev-docs/events.md` | | Event system | `dev-docs/events.md` |
| Threading model | `dev-docs/threading-model.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` | | 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` | | Configuration system | `dev-docs/configuration.md` |
| Networking & packets | `dev-docs/networking-packets.md` | | Networking & packets | `dev-docs/networking-packets.md` |
| IP bans, blocklists & allowlists (incl. unblocking a player) | `dev-docs/ip-bans-and-allowlists.md` | | IP bans, blocklists & allowlists (incl. unblocking a player) | `dev-docs/ip-bans-and-allowlists.md` |
@ -99,18 +94,7 @@ Then copy only the relevant skill files based on the task:
| Migrate persistence (WorldSave) | `migrate-from-runuo/migrate-persistence` | | Migrate persistence (WorldSave) | `migrate-from-runuo/migrate-persistence` |
| Migrate multi-file system | `migrate-from-runuo/migrate-systems` | | Migrate multi-file system | `migrate-from-runuo/migrate-systems` |
To enable a skill — Claude Code loads `.claude/skills/<name>/SKILL.md`; a bare `.md` dropped To enable a skill: `cp dev-docs/claude-skills/<name>.md .claude/skills/`
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/<name> && cp dev-docs/claude-skills/<name>.md .claude/skills/<name>/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/<name>" is the source path, not the name):
mkdir -p .claude/skills/<name> && cp dev-docs/claude-skills/migrate-from-runuo/<name>.md .claude/skills/<name>/SKILL.md
```
Migration skills reference the deep docs in `dev-docs/runuo-migration-docs/` and point to existing ModernUO skills for best practices. Migration skills reference the deep docs in `dev-docs/runuo-migration-docs/` and point to existing ModernUO skills for best practices.

View file

@ -63,15 +63,8 @@
<CodeAnalysisRuleSet>..\..\Rules.ruleset</CodeAnalysisRuleSet> <CodeAnalysisRuleSet>..\..\Rules.ruleset</CodeAnalysisRuleSet>
<AnalysisLevel>latest</AnalysisLevel> <AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup> </PropertyGroup>
<!-- Event-loop time accounting, compiled out unless requested:
dotnet build -p:EventLoopProfiling=true
See dev-docs/debugging-event-loop.md. Placed last so it appends to whatever the
configuration groups above set DefineConstants to. -->
<PropertyGroup Condition="'$(EventLoopProfiling)'=='true'">
<DefineConstants>$(DefineConstants);EVENT_LOOP_PROFILING</DefineConstants>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Serilog" Version="4.4.0" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" /> <PackageReference Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Nerdbank.GitVersioning" Condition="!Exists('packages.config')"> <PackageReference Include="Nerdbank.GitVersioning" Condition="!Exists('packages.config')">

View file

@ -3,16 +3,12 @@
"level": "VerySlow", "level": "VerySlow",
"active": 0.4, "active": 0.4,
"passive": 0.8, "passive": 0.8,
"activeMove": 0.9,
"passiveMove": 1.5,
"types": [] "types": []
}, },
{ {
"level": "Slow", "level": "Slow",
"active": 0.3, "active": 0.3,
"passive": 0.6, "passive": 0.6,
"activeMove": 0.6,
"passiveMove": 1.2,
"types": [ "types": [
"AntLion", "ArcticOgreLord", "BogThing", "AntLion", "ArcticOgreLord", "BogThing",
"Bogle", "BoneKnight", "EarthElemental", "Bogle", "BoneKnight", "EarthElemental",
@ -32,8 +28,6 @@
"level": "Medium", "level": "Medium",
"active": 0.25, "active": 0.25,
"passive": 0.5, "passive": 0.5,
"activeMove": 0.45,
"passiveMove": 1.05,
"types": [ "types": [
"AcidElemental", "AgapiteElemental", "Alligator", "AcidElemental", "AgapiteElemental", "Alligator",
"AncientLich", "Betrayer", "Bird", "AncientLich", "Betrayer", "Bird",
@ -114,8 +108,6 @@
"level": "Fast", "level": "Fast",
"active": 0.2, "active": 0.2,
"passive": 0.4, "passive": 0.4,
"activeMove": 0.3,
"passiveMove": 0.9,
"types": [ "types": [
"LordOaks", "Silvani", "AirElemental", "LordOaks", "Silvani", "AirElemental",
"AncientWyrm", "Balron", "BladeSpirits", "AncientWyrm", "Balron", "BladeSpirits",
@ -147,8 +139,6 @@
"level": "VeryFast", "level": "VeryFast",
"active": 0.125, "active": 0.125,
"passive": 0.30, "passive": 0.30,
"activeMove": 0.125,
"passiveMove": 0.6,
"types": [ "types": [
"Barracoon", "Mephitis", "Neira", "Barracoon", "Mephitis", "Neira",
"Rikktor", "Semidar", "EnergyVortex", "Rikktor", "Semidar", "EnergyVortex",

View file

@ -14,11 +14,4 @@ public sealed class BuildOptions
public string? Arch { get; set; } public string? Arch { get; set; }
public bool SkipPrereqs { get; set; } public bool SkipPrereqs { get; set; }
public bool Interactive { get; set; } public bool Interactive { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool CheckPrereqsOnly { get; set; }
} }

View file

@ -17,5 +17,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.57.2" /> <PackageReference Include="Spectre.Console" Version="0.57.2" />
<PackageReference Update="Serilog" Version="4.4.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -1,4 +1,3 @@
using System.Runtime.InteropServices;
using BuildTool.Platform; using BuildTool.Platform;
using BuildTool.Publishing; using BuildTool.Publishing;
@ -32,10 +31,8 @@ public static class NativeLibraryChecker
"Linux", "Linux",
[ [
".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0", ".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0",
"Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata", "Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev",
" (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)", "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel",
" (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" "CentOS: Also requires epel-release and CRB enabled"
] ]
), ),
@ -179,59 +176,82 @@ public static class NativeLibraryChecker
return results; return results;
} }
/// <summary>
/// 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.
/// </summary>
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<PrerequisiteResult> CheckLinux(PlatformInfo platform) private static List<PrerequisiteResult> CheckLinux(PlatformInfo platform)
{
return platform.PackageManager switch
{
PackageManager.Apt => CheckLinuxApt(),
PackageManager.Dnf => CheckLinuxDnf(platform),
_ => CheckLinuxGeneric(platform)
};
}
private static List<PrerequisiteResult> CheckLinuxApt()
{ {
var results = new List<PrerequisiteResult>(); var results = new List<PrerequisiteResult>();
var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev" };
var missing = new List<string>(); var missing = new List<string>();
foreach (var (name, minSoVersion, maxSoVersion) in _linuxLibraries) foreach (var package in packages)
{ {
var found = CanLoad(name, minSoVersion, maxSoVersion); var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}");
var installed = result.Success && result.StandardOutput.Contains("ii");
if (!found) if (!installed)
{ {
missing.Add(name); missing.Add(package);
} }
results.Add(new PrerequisiteResult results.Add(new PrerequisiteResult
{ {
Name = name, Name = package,
Passed = found, Passed = installed,
Details = found ? "Found" : "Not found" Details = installed ? "Installed" : "Not installed"
}); });
} }
var hasTimeZoneData = HasTimeZoneData(); if (missing.Count > 0)
if (!hasTimeZoneData)
{ {
missing.Add("tzdata"); 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)}"
});
} }
results.Add(new PrerequisiteResult return results;
{ }
Name = "tzdata",
Passed = hasTimeZoneData,
Details = hasTimeZoneData ? "Found" : "Not found — every zone except UTC will throw"
});
if (missing.Count == 0) private static List<PrerequisiteResult> CheckLinuxDnf(PlatformInfo platform)
{
var results = new List<PrerequisiteResult>();
var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel" };
var missing = new List<string>();
foreach (var package in packages)
{ {
return results; var result = ProcessRunner.RunCaptured("rpm", $"-q {package}");
var installed = result.Success;
if (!installed)
{
missing.Add(package);
}
results.Add(new PrerequisiteResult
{
Name = package,
Passed = installed,
Details = installed ? "Installed" : "Not installed"
});
} }
if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true) // Check if this is CentOS (needs EPEL)
var isCentOs = platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true;
if (isCentOs && missing.Count > 0)
{ {
results.Add(new PrerequisiteResult results.Add(new PrerequisiteResult
{ {
@ -243,131 +263,48 @@ public static class NativeLibraryChecker
}); });
} }
results.Add(new PrerequisiteResult if (missing.Count > 0)
{ {
Name = "Install all missing", results.Add(new PrerequisiteResult
Passed = false, {
IsWarning = true, Name = "Install all missing",
Details = "Install the missing dependencies. The -dev/-devel packages are not required:", Passed = false,
InstallCommand = BuildInstallCommand(platform, missing) IsWarning = true,
}); Details = "Run the following command to install all missing dependencies:",
InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}"
});
}
return results; return results;
} }
/// <summary> private static List<PrerequisiteResult> 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.
/// </summary>
private static bool HasTimeZoneData()
{ {
try var results = new List<PrerequisiteResult>();
{
return TimeZoneInfo.GetSystemTimeZones().Count > 1;
}
catch
{
return false;
}
}
/// <summary> // Use ldconfig to check for shared libraries
/// Asks the loader directly rather than querying a package database or scanning ldconfig's var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p");
/// cache, both of which answer a different question and can disagree with what dlopen will do. var ldOutput = ldResult.Success ? ldResult.StandardOutput : "";
/// 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.
/// </summary>
private static bool CanLoad(string library, int minSoVersion, int maxSoVersion)
{
if (TryLoadAndFree($"{library}.so"))
{
return true;
}
for (var soVersion = maxSoVersion; soVersion >= minSoVersion; soVersion--) var libraries = new Dictionary<string, string>
{ {
if (TryLoadAndFree($"{library}.so.{soVersion}")) ["libicu"] = "libicuuc",
["libdeflate"] = "libdeflate",
["zstd"] = "libzstd",
["libargon2"] = "libargon2"
};
foreach (var (name, soName) in libraries)
{
var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase);
results.Add(new PrerequisiteResult
{ {
return true; Name = name,
} Passed = found,
Details = found ? "Found" : "Not found — install using your package manager"
});
} }
return false; return results;
}
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<string> 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;
/// <summary>
/// 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.
/// </summary>
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";
} }
} }

View file

@ -4,7 +4,6 @@ using BuildTool.Interactive;
using BuildTool.Platform; using BuildTool.Platform;
using BuildTool.Prerequisites; using BuildTool.Prerequisites;
using BuildTool.Publishing; using BuildTool.Publishing;
using Spectre.Console;
Console.OutputEncoding = Encoding.UTF8; Console.OutputEncoding = Encoding.UTF8;
@ -37,22 +36,6 @@ options.Os ??= detectedPlatform.OsRid;
options.Arch ??= detectedPlatform.ArchRid; options.Arch ??= detectedPlatform.ArchRid;
var rid = $"{options.Os}-{options.Arch}"; 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 // Run prerequisite checks unless skipped
if (!options.SkipPrereqs) if (!options.SkipPrereqs)
{ {
@ -125,12 +108,6 @@ static BuildOptions ParseArguments(string[] args)
hasNamedArgs = true; hasNamedArgs = true;
break; break;
} }
case "--check-prereqs":
{
options.CheckPrereqsOnly = true;
hasNamedArgs = true;
break;
}
case "--interactive": case "--interactive":
{ {
options.Interactive = true; options.Interactive = true;

View file

@ -5,16 +5,17 @@
<RootNamespace>Server.Tests</RootNamespace> <RootNamespace>Server.Tests</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.SkippableFact" Version="1.5.61" /> <PackageReference Include="xunit.SkippableFact" Version="1.5.61" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<ProjectReference Include="..\Application\Application.csproj" /> <ProjectReference Include="..\Application\Application.csproj" />
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" /> <DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
<ProjectReference Include="..\UOContent\UOContent.csproj" /> <ProjectReference Include="..\UOContent\UOContent.csproj" />
<PackageReference Update="Serilog" Version="4.4.0" />
</ItemGroup> </ItemGroup>
<!-- Copy native ioring.dll for tests --> <!-- Copy native ioring.dll for tests -->
<ItemGroup> <ItemGroup>

View file

@ -208,272 +208,6 @@ public class DecayRegistrationTests
item.Delete(); 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. // Dropping into a container must untrack; taking it back out to the ground must re-track.
[Fact] [Fact]
public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay() public void ItemMovedIntoContainerThenBackToGround_IsRegisteredForDecay()

View file

@ -1,95 +0,0 @@
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();
}
}
}

View file

@ -1,311 +0,0 @@
using System;
using System.Collections.Generic;
using Server.Collections;
using Xunit;
namespace Server.Tests;
[Collection("Sequential Server Tests")]
public class DamageEntryTests
{
private class TestMobile : Mobile
{
}
private class PetMobile : Mobile
{
public Mobile Master { get; set; }
public override Mobile GetDamageMaster(Mobile damagee) => Master;
}
private static List<Mobile> Damagers(Mobile victim)
{
var result = new List<Mobile>();
foreach (var de in victim.DamageEntries)
{
result.Add(de.Damager);
}
return result;
}
[Fact]
public void FreshMobile_HasNoEntries()
{
var m = new TestMobile();
try
{
Assert.Equal(0, m.DamageEntries.Count);
Assert.Null(m.FindMostRecentDamageEntry(true));
Assert.Null(m.FindLeastRecentDamageEntry(true));
Assert.Null(m.FindMostTotalDamageEntry(true));
Assert.Null(m.FindLeastTotalDamageEntry(true));
Assert.Null(m.FindDamageEntryFor(m));
}
finally
{
m.Delete();
}
}
[Fact]
public void RegisterDamage_OrdersLeastRecentToMostRecent()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(20, b);
victim.RegisterDamage(5, a); // a becomes most recent again
Assert.Equal(2, victim.DamageEntries.Count);
Assert.Equal(new[] { b, a }, Damagers(victim));
Assert.Equal(15, victim.FindDamageEntryFor(a).DamageGiven);
Assert.Same(a, victim.FindMostRecentDamager(true));
Assert.Same(b, victim.FindLeastRecentDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void FindRecent_HonorsAllowSelf()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(10, victim); // self is most recent
Assert.Same(victim, victim.FindMostRecentDamager(true));
Assert.Same(a, victim.FindMostRecentDamager(false));
Assert.Same(a, victim.FindLeastRecentDamager(false));
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void FindLeastRecent_HonorsAllowSelf()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RegisterDamage(10, victim); // self is least recent, so the head is the one to skip
victim.RegisterDamage(10, a);
Assert.Same(victim, victim.FindLeastRecentDamager(true));
Assert.Same(a, victim.FindLeastRecentDamager(false));
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void FindTotal_PicksByDamage_MostRecentWinsTies()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
var c = new TestMobile();
try
{
victim.RegisterDamage(30, a);
victim.RegisterDamage(30, b); // ties a; b is more recent
victim.RegisterDamage(1, c);
Assert.Same(b, victim.FindMostTotalDamager(true));
Assert.Same(c, victim.FindLeastTotalDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
c.Delete();
}
}
[Fact]
public void FindLeastTotal_MostRecentWinsTies()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
var c = new TestMobile();
try
{
victim.RegisterDamage(30, a);
victim.RegisterDamage(5, b);
victim.RegisterDamage(5, c); // ties b for the minimum; c is more recent
Assert.Same(a, victim.FindMostTotalDamager(true));
Assert.Same(c, victim.FindLeastTotalDamager(true));
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
c.Delete();
}
}
[Fact]
public void Prune_RemovesExpiredPrefix_KeepsOrder()
{
var start = Core._now;
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
victim.RegisterDamage(10, b); // a is now expired, b is live
Assert.Equal(new[] { b }, Damagers(victim));
Assert.Null(victim.FindDamageEntryFor(a));
}
finally
{
Core._now = start;
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void Prune_AllExpired_EmptiesList()
{
var start = Core._now;
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
victim.RegisterDamage(10, a);
victim.RegisterDamage(10, b);
Core._now = start + DamageEntry.ExpireDelay + TimeSpan.FromSeconds(1);
Assert.Equal(0, victim.DamageEntries.Count);
Assert.Null(victim.FindMostRecentDamageEntry(true));
}
finally
{
Core._now = start;
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void ClearDamageEntries_UnlinksEveryNode()
{
var victim = new TestMobile();
var a = new TestMobile();
var b = new TestMobile();
try
{
var ea = victim.RegisterDamage(10, a);
var eb = victim.RegisterDamage(10, b);
victim.ClearDamageEntries();
Assert.Equal(0, victim.DamageEntries.Count);
Assert.False(ea.OnLinkList);
Assert.False(eb.OnLinkList);
Assert.Null(ea.Next);
Assert.Null(ea.Previous);
Assert.Null(eb.Next);
Assert.Null(eb.Previous);
}
finally
{
victim.Delete();
a.Delete();
b.Delete();
}
}
[Fact]
public void FullHitPoints_ClearsEntries()
{
var victim = new TestMobile();
var a = new TestMobile();
try
{
victim.RawStr = 50; // HitsMax follows Str for a base Mobile
victim.Hits = 10;
victim.RegisterDamage(10, a);
Assert.Equal(1, victim.DamageEntries.Count);
// Also stops the HitsTimer the Hits = 10 write started, so the test leaves no timer behind.
victim.Hits = victim.HitsMax;
Assert.Equal(0, victim.DamageEntries.Count);
}
finally
{
victim.Delete();
a.Delete();
}
}
[Fact]
public void RegisterDamage_AccumulatesResponsibleMaster()
{
var victim = new TestMobile();
var master = new TestMobile();
var pet = new PetMobile { Master = master };
try
{
victim.RegisterDamage(10, pet);
var entry = victim.RegisterDamage(5, pet);
Assert.Same(pet, entry.Damager);
Assert.Equal(15, entry.DamageGiven);
Assert.NotNull(entry.Responsible);
Assert.Single(entry.Responsible);
Assert.Same(master, entry.Responsible[0].Damager);
Assert.Equal(15, entry.Responsible[0].DamageGiven);
Assert.False(entry.Responsible[0].OnLinkList); // sub-entries never join the main list
}
finally
{
victim.Delete();
master.Delete();
pet.Delete();
}
}
}

View file

@ -1,69 +0,0 @@
using Xunit;
namespace Server.Tests;
/// <summary>
/// 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.
/// </summary>
[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);
}
}

View file

@ -1,76 +0,0 @@
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];
}
/// <summary>
/// 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.
/// </summary>
[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;
}
}
/// <summary>
/// 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.
/// </summary>
[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();
}
}

View file

@ -1,189 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using Xunit;
namespace Server.Tests;
public class AnchoredTimeTests
{
private static (BufferWriter Writer, Func<TimeSpan, IGenericReader> 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<AnchoredEntity>
{
public AnchoredPersistence(int priority) : base("AnchoredTrip", priority, 1, 0x7FFFFFFF)
{
}
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}
}

View file

@ -1,234 +0,0 @@
/*************************************************************************
* 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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace Server;
public enum LoopPhase
{
MobileDeltas,
ItemDeltas,
TimerSlice,
NetworkSlice,
LoopTasks,
WorldSnapshot,
}
/// <summary>
/// Event-loop time accounting, compiled out of normal builds. Build with
/// <c>-p:EventLoopProfiling=true</c> to enable; every hook is
/// <c>[Conditional("EVENT_LOOP_PROFILING")]</c>, 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.
/// </summary>
/// <remarks>
/// Each one-second sample decomposes wall time into work (per <see cref="LoopPhase"/>), 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.
/// </remarks>
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;
/// <summary>Number of samples recorded so far (capped at the ring size).</summary>
public static int SampleCount => _ringCount;
/// <summary>The sample currently being accumulated (not yet in the ring).</summary>
public static Sample Current => _current;
/// <summary>
/// Copies the newest <paramref name="count"/> completed samples, oldest first.
/// </summary>
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++;
}
}
}

View file

@ -42,47 +42,10 @@ public sealed class EventLoopContext : SynchronizationContext
public override SynchronizationContext CreateCopy() => new EventLoopContext(); public override SynchronizationContext CreateCopy() => new EventLoopContext();
/// <summary> public void Post(Action d, Priority priority = Priority.Normal) =>
/// True when no callbacks are waiting to run.
/// </summary>
/// <remarks>
/// <see cref="ExecuteTasks"/> drains at most <c>_maxPerFrame</c> callbacks, so work can
/// legitimately be left over. The event loop checks this before sleeping so a backlog keeps
/// it running instead.
/// </remarks>
public bool IsEmpty => _queue.IsEmpty && _priorityQueue.IsEmpty;
public void Post(Action d, Priority priority = Priority.Normal)
{
(priority == Priority.High ? _priorityQueue : _queue).Enqueue(d); (priority == Priority.High ? _priorityQueue : _queue).Enqueue(d);
WakeEventLoop();
}
public override void Post(SendOrPostCallback d, object state) public override void Post(SendOrPostCallback d, object state) => _queue.Enqueue(() => d(state));
{
_queue.Enqueue(() => d(state));
WakeEventLoop();
}
/// <summary>
/// Nudges the game loop in case it is asleep: the loop blocks on network I/O, which a queue
/// push alone does not signal.
/// </summary>
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) public override void Send(SendOrPostCallback d, object state)
{ {
@ -100,8 +63,6 @@ public sealed class EventLoopContext : SynchronizationContext
evt.Set(); evt.Set();
}); });
WakeEventLoop();
evt.WaitOne(); evt.WaitOne();
} }

View file

@ -37,13 +37,6 @@ public class AccountLoginEventArgs
public bool Accepted { get; set; } public bool Accepted { get; set; }
public ALRReason RejectReason { get; set; } public ALRReason RejectReason { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool Deferred { get; set; }
} }
public static partial class EventSink public static partial class EventSink

View file

@ -44,10 +44,10 @@ public partial class Container : Item
internal int _version; internal int _version;
[SerializableField(3)] [SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeLiftOverride))]
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _liftOverride; private bool _liftOverride;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeLiftOverride() => _liftOverride; private bool ShouldSerializeLiftOverride() => _liftOverride;
public Container(int itemID) : base(itemID) public Container(int itemID) : base(itemID)
@ -84,7 +84,6 @@ public partial class Container : Item
[EncodedInt] [EncodedInt]
[SerializableProperty(0)] [SerializableProperty(0)]
[SaveFlag(nameof(ShouldSerializeMaxItems), nameof(MaxItemsDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public int MaxItems public int MaxItems
{ {
@ -97,13 +96,14 @@ public partial class Container : Item
} }
} }
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeMaxItems() => _maxItems != -1; private bool ShouldSerializeMaxItems() => _maxItems != -1;
[SerializableFieldDefault(0)]
private int MaxItemsDefaultValue() => -1; private int MaxItemsDefaultValue() => -1;
[EncodedInt] [EncodedInt]
[SerializableProperty(1)] [SerializableProperty(1)]
[SaveFlag(nameof(ShouldSerializeGumpId), nameof(GumpIDDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public int GumpID public int GumpID
{ {
@ -115,13 +115,14 @@ public partial class Container : Item
} }
} }
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeGumpId() => _gumpID != -1; private bool ShouldSerializeGumpId() => _gumpID != -1;
[SerializableFieldDefault(1)]
private int GumpIDDefaultValue() => -1; private int GumpIDDefaultValue() => -1;
[EncodedInt] [EncodedInt]
[SerializableProperty(2)] [SerializableProperty(2)]
[SaveFlag(nameof(ShouldSerializeDropSound), nameof(DropSoundDefaultValue))]
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public int DropSound public int DropSound
{ {
@ -133,8 +134,10 @@ public partial class Container : Item
} }
} }
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeDropSound() => _dropSound != -1; private bool ShouldSerializeDropSound() => _dropSound != -1;
[SerializableFieldDefault(2)]
private int DropSoundDefaultValue() => -1; private int DropSoundDefaultValue() => -1;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]

View file

@ -311,7 +311,7 @@ public class DecayScheduler : Timer
if (timeUntilDecay > _bucketInterval) if (timeUntilDecay > _bucketInterval)
{ {
// Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow // Item was moved (SetLastMoved called) - re-bucket or move to overflow
if (timeUntilDecay > _totalBucketSpan) if (timeUntilDecay > _totalBucketSpan)
{ {
// Extended beyond total span - move to overflow // 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 // 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. // already left the queue; re-registering as-is would spin on a due time in the past.
item.RestartDecay(); item.SetLastMoved();
} }
} }
} }

View file

@ -335,25 +335,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public virtual bool Decays => Movable && Visible && Spawner == null; public virtual bool Decays => Movable && Visible && Spawner == null;
private DateTime _lastMoved; public DateTime LastMoved { get; set; }
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)] [CommandProperty(AccessLevel.GameMaster)]
public bool Stackable public bool Stackable
@ -391,7 +373,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
} }
Delta(ItemDelta.Update); Delta(ItemDelta.Update);
RestartDecay(); UpdateDecayRegistration();
} }
} }
} }
@ -407,7 +389,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
SetFlag(ImplFlag.Movable, value); SetFlag(ImplFlag.Movable, value);
Delta(ItemDelta.Update); Delta(ItemDelta.Update);
RestartDecay(); UpdateDecayRegistration();
} }
} }
} }
@ -767,12 +749,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public static bool ScissorCopyLootType { get; set; } public static bool ScissorCopyLootType { get; set; }
/// <summary>
/// True when the item was produced by the crafting system rather than bought or looted.
/// </summary>
[CommandProperty(AccessLevel.GameMaster)]
public bool PlayerConstructed { get; set; }
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public bool QuestItem public bool QuestItem
{ {
@ -863,7 +839,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public virtual void Serialize(IGenericWriter writer) public virtual void Serialize(IGenericWriter writer)
{ {
writer.Write(11); // version writer.Write(9); // version
var flags = SaveFlag.None; var flags = SaveFlag.None;
@ -973,11 +949,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
{ {
flags |= SaveFlag.SavedFlags; flags |= SaveFlag.SavedFlags;
} }
if (info.m_DecayReset > LastMoved)
{
flags |= SaveFlag.DecayReset;
}
} }
if (info == null || info.m_Weight < 0) if (info == null || info.m_Weight < 0)
@ -1008,21 +979,16 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
flags |= SaveFlag.ImplFlags; flags |= SaveFlag.ImplFlags;
} }
if (PlayerConstructed)
{
flags |= SaveFlag.PlayerConstructed;
}
writer.Write((int)flags); writer.Write((int)flags);
// Anchored: shifted by downtime at load, so time-since-moved is preserved and the /* begin last moved time optimization */
// bytes are stable across saves while the item does not move. var ticks = LastMoved.Ticks;
writer.WriteAnchoredTime(LastMoved); var now = Core.Now.Ticks;
if (GetSaveFlag(flags, SaveFlag.DecayReset)) var minutes = new TimeSpan(now - ticks).TotalMinutes;
{
writer.WriteAnchoredTime(info.m_DecayReset); writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue));
} /* end */
if (GetSaveFlag(flags, SaveFlag.Direction)) if (GetSaveFlag(flags, SaveFlag.Direction))
{ {
@ -1341,12 +1307,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
OnMapChange(); 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) if (old == null || old == Map.Internal)
{ {
InvalidateProperties(); InvalidateProperties();
@ -1577,7 +1537,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
if (oldValue != value) if (oldValue != value)
{ {
RestartDecay(); UpdateDecayRegistration();
} }
} }
} }
@ -1771,7 +1731,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|| info.m_HeldBy != null || info.m_HeldBy != null
|| info.m_BlessedFor != null || info.m_BlessedFor != null
|| info.m_Spawner != null || info.m_Spawner != null
|| info.m_DecayReset != default
|| info.m_TempFlags != 0 || info.m_TempFlags != 0
|| info.m_SavedFlags != 0 || info.m_SavedFlags != 0
|| info.m_Weight >= 0; || info.m_Weight >= 0;
@ -2356,64 +2315,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public virtual bool OnDecay() => public virtual bool OnDecay() =>
CanDecay() && Region.Find(Location, Map).OnDecay(this); CanDecay() && Region.Find(Location, Map).OnDecay(this);
public DateTime ScheduledDecayTime public DateTime ScheduledDecayTime => LastMoved + DecayTime;
{
get
{
var reset = DecayResetTime;
var lastMoved = LastMoved;
return (reset > lastMoved ? reset : lastMoved) + DecayTime;
}
}
/// <summary>
/// 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 <see cref="LastMoved" />.
/// </summary>
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;
}
}
}
/// <summary>
/// Restarts the decay countdown without touching <see cref="LastMoved" />: call when decay
/// eligibility changes state (Movable/Visible/Spawner) or a region refuses a decay, where a
/// stale <see cref="LastMoved" /> would otherwise decay the item on the next tick.
/// Stamps <see cref="DecayResetTime" /> only when that extends the current deadline, then
/// updates the scheduler registration.
/// </summary>
public void RestartDecay()
{
if (CanDecay())
{
var now = Core.Now;
if (ScheduledDecayTime < now + DecayTime)
{
DecayResetTime = now;
}
}
UpdateDecayRegistration();
}
public void UpdateDecayRegistration() public void UpdateDecayRegistration()
{ {
@ -2423,12 +2325,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
{ {
DecayScheduler.Register(this); 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() public void SetLastMoved()
@ -2461,11 +2357,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
} }
Amount += dropped.Amount; Amount += dropped.Amount;
if (PlayerConstructed != dropped.PlayerConstructed)
{
PlayerConstructed = false;
}
dropped.Delete(); dropped.Delete();
if (playSound && from != null) if (playSound && from != null)
@ -2766,8 +2657,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
switch (version) switch (version)
{ {
case 11:
case 10:
case 9: case 9:
case 8: case 8:
case 7: case 7:
@ -2775,11 +2664,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
{ {
var flags = (SaveFlag)reader.ReadInt(); var flags = (SaveFlag)reader.ReadInt();
if (version >= 11) if (version < 7)
{
LastMoved = reader.ReadAnchoredTime();
}
else if (version < 7)
{ {
LastMoved = reader.ReadDeltaTime(); LastMoved = reader.ReadDeltaTime();
} }
@ -2797,18 +2682,6 @@ public partial class Item : IHued, IComparable<Item>, 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)) if (GetSaveFlag(flags, SaveFlag.Direction))
{ {
m_Direction = (Direction)reader.ReadByte(); m_Direction = (Direction)reader.ReadByte();
@ -2981,8 +2854,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
AcquireCompactInfo().m_SavedFlags = reader.ReadEncodedInt(); AcquireCompactInfo().m_SavedFlags = reader.ReadEncodedInt();
} }
PlayerConstructed = GetSaveFlag(flags, SaveFlag.PlayerConstructed);
if (m_Map != null && m_Parent == null) if (m_Map != null && m_Parent == null)
{ {
m_Map.OnEnter(this); m_Map.OnEnter(this);
@ -3454,12 +3325,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
m_DeltaFlags &= ~flags; m_DeltaFlags &= ~flags;
} }
/// <summary>
/// True when deltas remain queued after a <see cref="ProcessDeltaQueue"/> pass, which is
/// bounded by the count it saw on entry. The event loop consults this before sleeping.
/// </summary>
public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0;
public static void ProcessDeltaQueue() public static void ProcessDeltaQueue()
{ {
var limit = m_DeltaQueue.Count; var limit = m_DeltaQueue.Count;
@ -3566,7 +3431,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
for (var i = 0; i < props.Length; i++) for (var i = 0; i < props.Length; i++)
{ {
var p = props[i]; var p = props[i];
if (p.GetCustomAttribute<IgnoreDupeAttribute>(true) != null || !p.CanRead || !p.CanWrite) if (p.GetCustomAttribute(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite)
{ {
continue; continue;
} }
@ -4472,8 +4337,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public ISpawner m_Spawner; public ISpawner m_Spawner;
public DateTime m_DecayReset;
public int m_TempFlags; public int m_TempFlags;
public double m_Weight = -1; public double m_Weight = -1;
@ -4511,8 +4374,6 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
HeldBy = 0x00800000, HeldBy = 0x00800000,
IntWeight = 0x01000000, IntWeight = 0x01000000,
SavedFlags = 0x02000000, SavedFlags = 0x02000000,
NullWeight = 0x04000000, NullWeight = 0x04000000
PlayerConstructed = 0x08000000,
DecayReset = 0x10000000
} }
} }

View file

@ -39,181 +39,10 @@ public static class Core
{ {
private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core)); private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core));
// Written off-loop (Kill, RequestSnapshot); volatile because the loop blocks between reads. private static bool _performProcessKill;
private static volatile bool _performProcessKill;
private static bool _restartOnKill; private static bool _restartOnKill;
private static volatile bool _performSnapshot; private static bool _performSnapshot;
private static string _snapshotPath; 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;
/// <summary>
/// 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.
/// </summary>
public static int EventLoopIdleWaitMs => _eventLoopIdleWaitMs;
/// <summary>
/// True when idle sleeping was disabled at startup because the host cannot honor short
/// waits, overriding whatever <c>server.eventLoopIdleWaitMs</c> was configured to.
/// </summary>
public static bool IdleSleepUnsupported { get; private set; }
/// <summary>
/// Whether idle sleeping is currently suspended because the host returned waits late.
/// </summary>
/// <remarks>
/// Compared by subtraction, never directly: tick counts can start enormous and wrap.
/// See dev-docs/tick-counts.md.
/// </remarks>
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;
/// <summary>
/// Once a second, suspends idle sleeping (with escalating duration) if the host keeps
/// returning idle waits a full tick or more late.
/// </summary>
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 bool _crashed;
private static string _baseDirectory; private static string _baseDirectory;
@ -282,6 +111,14 @@ public static class Core
public static long Uptime => TickCount - _firstTick; 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 public static string BaseDirectory
{ {
get get
@ -398,10 +235,6 @@ public static class Core
{ {
_restartOnKill = restart; _restartOnKill = restart;
_performProcessKill = true; _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) public static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
@ -591,45 +424,6 @@ public static class Core
ServerConfiguration.Load(); 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); var assemblyPath = Path.Join(BaseDirectory, AssembliesConfiguration);
// Load UOContent.dll // Load UOContent.dll
@ -646,8 +440,10 @@ public static class Core
AssemblyHandler.LoadAssemblies(assemblyFiles); AssemblyHandler.LoadAssemblies(assemblyFiles);
// First-boot interactive setup. After assemblies load so content can register prompts, // First-boot interactive setup. Runs after assemblies are loaded (so content can
// before any Serilog output so prompts are not interleaved with the async console sink. // 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?").
AssemblyHandler.Invoke("ConfigurePrompts"); AssemblyHandler.Invoke("ConfigurePrompts");
logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription); logger.Information("Running on {Framework}", RuntimeInformation.FrameworkDescription);
@ -657,11 +453,6 @@ public static class Core
_now = DateTime.UtcNow; _now = DateTime.UtcNow;
_firstTick = _tickCount = GetTimestamp(); _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); Timer.Init(_tickCount);
AssemblyHandler.Invoke("Configure"); AssemblyHandler.Invoke("Configure");
@ -678,71 +469,41 @@ public static class Core
NetState.Start(); NetState.Start();
PingServer.Start(); PingServer.Start();
EventSink.InvokeServerStarted(); 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(); RunEventLoop();
} }
/// <summary>
/// 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.
/// </summary>
private static bool IsIdle() =>
!Mobile.HasQueuedDeltas && !Item.HasQueuedDeltas && LoopContext.IsEmpty && NetState.IsIdle;
public static void RunEventLoop() public static void RunEventLoop()
{ {
try 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) while (!Closing)
{ {
_tickCount = GetTimestamp(); _tickCount = GetTimestamp();
_now = DateTime.UtcNow; _now = DateTime.UtcNow;
EventLoopProfiler.IterationStart(_tickCount);
EventLoopProfiler.PhaseStart(LoopPhase.MobileDeltas);
Mobile.ProcessDeltaQueue(); Mobile.ProcessDeltaQueue();
EventLoopProfiler.PhaseEnd(LoopPhase.MobileDeltas);
EventLoopProfiler.PhaseStart(LoopPhase.ItemDeltas);
Item.ProcessDeltaQueue(); Item.ProcessDeltaQueue();
EventLoopProfiler.PhaseEnd(LoopPhase.ItemDeltas);
EventLoopProfiler.PhaseStart(LoopPhase.TimerSlice);
Timer.Slice(_tickCount); Timer.Slice(_tickCount);
EventLoopProfiler.PhaseEnd(LoopPhase.TimerSlice);
// Handle networking // Handle networking
EventLoopProfiler.PhaseStart(LoopPhase.NetworkSlice);
NetState.Slice(); NetState.Slice();
EventLoopProfiler.PhaseEnd(LoopPhase.NetworkSlice);
// Execute captured post-await methods (like Timer.Pause) // Execute captured post-await methods (like Timer.Pause)
EventLoopProfiler.PhaseStart(LoopPhase.LoopTasks);
LoopContext.ExecuteTasks(); LoopContext.ExecuteTasks();
EventLoopProfiler.PhaseEnd(LoopPhase.LoopTasks);
Timer.CheckTimerPool(); // Check for pool depletion so we can async refill it. Timer.CheckTimerPool(); // Check for pool depletion so we can async refill it.
if (_performSnapshot) if (_performSnapshot)
{ {
EventLoopProfiler.PhaseStart(LoopPhase.WorldSnapshot);
// Return value is the offset that can be used to fix timers that should drift // Return value is the offset that can be used to fix timers that should drift
World.Snapshot(_snapshotPath); World.Snapshot(_snapshotPath);
EventLoopProfiler.PhaseEnd(LoopPhase.WorldSnapshot);
_performSnapshot = false; _performSnapshot = false;
} }
@ -752,35 +513,29 @@ public static class Core
break; break;
} }
CheckSchedulerHealth(); if (sample++ == interval)
if (_eventLoopIdleWaitMs > 0 && _tickCount - _idleSleepSuspendedUntil >= 0 && IsIdle())
{ {
// Re-read the clock: a stale timestamp overstates the time to the next tick sample = 0;
// and sleeps straight past it. var nowRaw = Stopwatch.GetTimestamp();
var start = GetTimestamp();
var due = Timer.MillisecondsUntilNextTick(start); _currentCPS = frequency / (nowRaw - lastRaw);
if (due > 0)
if (!_cpsInitialized)
{ {
var requested = (int)Math.Min(due, _eventLoopIdleWaitMs); _averageCPS = _currentCPS;
_cpsInitialized = true;
}
else
{
_averageCPS += alpha * (_currentCPS - _averageCPS);
}
// The GC prefers to collect during idle sleeps, so its pauses land here by lastRaw = nowRaw;
// 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);
NetState.WaitForCompletion(requested); var sleepMs = (int)Timer.MillisecondsUntilNextTick(_tickCount);
if (sleepMs >= 2)
var elapsed = GetTimestamp() - start; {
EventLoopProfiler.SleepEnd(requested, elapsed); NetState.WaitForCompletion(sleepMs - 1);
_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++;
}
} }
} }
} }
@ -798,9 +553,6 @@ public static class Core
{ {
_snapshotPath = snapshotPath; _snapshotPath = snapshotPath;
_performSnapshot = true; _performSnapshot = true;
// Save requests arrive off-loop; wake so the snapshot starts now.
NetState.Wake();
} }
public static void VerifySerialization() public static void VerifySerialization()

View file

@ -42,7 +42,7 @@ public delegate void PromptCallback(Mobile from, string text);
public delegate void PromptStateCallback<in T>(Mobile from, string text, T state); public delegate void PromptStateCallback<in T>(Mobile from, string text, T state);
public class DamageEntry : IValueLinkListNode<DamageEntry> public class DamageEntry
{ {
public DamageEntry(Mobile damager) => Damager = damager; public DamageEntry(Mobile damager) => Damager = damager;
@ -57,11 +57,6 @@ public class DamageEntry : IValueLinkListNode<DamageEntry>
public List<DamageEntry> Responsible { get; set; } public List<DamageEntry> Responsible { get; set; }
public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0); public static TimeSpan ExpireDelay { get; set; } = TimeSpan.FromMinutes(2.0);
// Intrusive links for Mobile._damageEntries. Sub-entries in Responsible never join a list.
public DamageEntry Next { get; set; }
public DamageEntry Previous { get; set; }
public bool OnLinkList { get; set; }
} }
[Flags] [Flags]
@ -382,6 +377,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors = new List<AggressorInfo>(); Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>(); Aggressed = new List<AggressorInfo>();
NextSkillTime = Core.TickCount; NextSkillTime = Core.TickCount;
DamageEntries = new List<DamageEntry>();
} }
// Sectors // Sectors
@ -962,23 +958,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public static VisibleDamageType VisibleDamageType { get; set; } public static VisibleDamageType VisibleDamageType { get; set; }
private ValueLinkList<DamageEntry> _damageEntries; public List<DamageEntry> DamageEntries { get; private set; }
/// <summary>
/// Damage entries ordered least recent (head) to most recent (tail). Expired entries are
/// pruned on access. Enumerate with <c>foreach</c> (ascending) or <c>.ByDescending()</c>.
/// Mutate only through <see cref="RegisterDamage"/> and <see cref="ClearDamageEntries"/>.
/// Calling a ValueLinkList mutator on this reference compiles, but operates on a defensive copy
/// while still unlinking the real nodes — it silently corrupts the list.
/// </summary>
public ref readonly ValueLinkList<DamageEntry> DamageEntries
{
get
{
PruneExpiredDamageEntries();
return ref _damageEntries;
}
}
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public Mobile LastKiller { get; set; } public Mobile LastKiller { get; set; }
@ -1647,7 +1627,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player; public virtual bool KeepsItemsOnDeath => m_AccessLevel > AccessLevel.Player;
public bool HasTrade => m_NetState?.Trades?.Count > 0; public bool HasTrade => m_NetState?.Trades.Count > 0;
public bool NoMoveHS { get; set; } public bool NoMoveHS { get; set; }
@ -2040,7 +2020,10 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors[i].CanReportMurder = false; Aggressors[i].CanReportMurder = false;
} }
ClearDamageEntries(); // reset damage entries on full HP if (DamageEntries.Count > 0)
{
DamageEntries.Clear(); // reset damage entries on full HP
}
} }
else if (CanRegenHits) else if (CanRegenHits)
{ {
@ -2341,11 +2324,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public virtual void Serialize(IGenericWriter writer) public virtual void Serialize(IGenericWriter writer)
{ {
writer.Write(38); // version writer.Write(37); // version
writer.WriteAnchoredTime(LastStrGain); writer.WriteDeltaTime(LastStrGain);
writer.WriteAnchoredTime(LastIntGain); writer.WriteDeltaTime(LastIntGain);
writer.WriteAnchoredTime(LastDexGain); writer.WriteDeltaTime(LastDexGain);
byte hairflag = 0x00; byte hairflag = 0x00;
@ -5265,15 +5248,8 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
item.Name = oldItem.Name; item.Name = oldItem.Name;
item.Weight = oldItem.Weight; item.Weight = oldItem.Weight;
item.PlayerConstructed = oldItem.PlayerConstructed;
item.Amount = oldAmount - amount; 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); oldItem.OnAfterDuped(item);
@ -5762,54 +5738,24 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
} }
} }
// Entries are kept in LastDamage order, so expired entries are always a head prefix.
private void PruneExpiredDamageEntries()
{
#if DEBUG
for (var node = _damageEntries._first; node != null; node = node.Next)
{
Debug.Assert(
node.Next == null || node.Next.LastDamage >= node.LastDamage,
"Damage entries must be ordered by LastDamage ascending."
);
}
#endif
var first = _damageEntries._first;
if (first?.HasExpired != true)
{
return;
}
var firstLive = first.Next;
while (firstLive?.HasExpired == true)
{
firstLive = firstLive.Next;
}
if (firstLive == null)
{
_damageEntries.RemoveAll();
}
else
{
_damageEntries.RemoveAllBefore(firstLive);
}
}
public void ClearDamageEntries() => _damageEntries.RemoveAll();
public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager; public Mobile FindMostRecentDamager(bool allowSelf) => FindMostRecentDamageEntry(allowSelf)?.Damager;
public DamageEntry FindMostRecentDamageEntry(bool allowSelf) public DamageEntry FindMostRecentDamageEntry(bool allowSelf)
{ {
PruneExpiredDamageEntries(); for (var i = DamageEntries.Count - 1; i >= 0; --i)
for (var de = _damageEntries._last; de != null; de = de.Previous)
{ {
if (allowSelf || de.Damager != this) if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (allowSelf || de.Damager != this)
{ {
return de; return de;
} }
@ -5822,11 +5768,21 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf) public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
{ {
PruneExpiredDamageEntries(); for (var i = 0; i < DamageEntries.Count; ++i)
for (var de = _damageEntries._first; de != null; de = de.Next)
{ {
if (allowSelf || de.Damager != this) if (i < 0)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
--i;
}
else if (allowSelf || de.Damager != this)
{ {
return de; return de;
} }
@ -5837,17 +5793,24 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager; public Mobile FindMostTotalDamager(bool allowSelf) => FindMostTotalDamageEntry(allowSelf)?.Damager;
// Walks most recent first with a strict comparison so the most recent entry wins ties,
// matching the previous reverse-indexed loop.
public DamageEntry FindMostTotalDamageEntry(bool allowSelf) public DamageEntry FindMostTotalDamageEntry(bool allowSelf)
{ {
PruneExpiredDamageEntries();
DamageEntry mostTotal = null; DamageEntry mostTotal = null;
for (var de = _damageEntries._last; de != null; de = de.Previous) for (var i = DamageEntries.Count - 1; i >= 0; --i)
{ {
if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven)) if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
{ {
mostTotal = de; mostTotal = de;
} }
@ -5860,28 +5823,46 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf) public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
{ {
PruneExpiredDamageEntries(); DamageEntry mostTotal = null;
DamageEntry leastTotal = null; for (var i = DamageEntries.Count - 1; i >= 0; --i)
for (var de = _damageEntries._last; de != null; de = de.Previous)
{ {
if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven)) if (i >= DamageEntries.Count)
{ {
leastTotal = de; continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven))
{
mostTotal = de;
} }
} }
return leastTotal; return mostTotal;
} }
public DamageEntry FindDamageEntryFor(Mobile m) public DamageEntry FindDamageEntryFor(Mobile m)
{ {
PruneExpiredDamageEntries(); for (var i = DamageEntries.Count - 1; i >= 0; --i)
for (var de = _damageEntries._last; de != null; de = de.Previous)
{ {
if (de.Damager == m) if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i];
if (de.HasExpired)
{
DamageEntries.RemoveAt(i);
}
else if (de.Damager == m)
{ {
return de; return de;
} }
@ -5899,13 +5880,8 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
de.DamageGiven += amount; de.DamageGiven += amount;
de.LastDamage = Core.Now; de.LastDamage = Core.Now;
// Move to the tail so the list stays in LastDamage order. DamageEntries.Remove(de);
if (de.OnLinkList) DamageEntries.Add(de);
{
_damageEntries.Remove(de);
}
_damageEntries.AddLast(de);
var master = from.GetDamageMaster(this); var master = from.GetDamageMaster(this);
@ -6167,7 +6143,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
switch (version) 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 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object)
case 36: // Moved virtues to VirtueSystem case 36: // Moved virtues to VirtueSystem
case 35: // Moved short term murders to PlayerMurderSystem case 35: // Moved short term murders to PlayerMurderSystem
@ -6176,18 +6151,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
case 32: // Removed StuckMenu case 32: // Removed StuckMenu
case 31: case 31:
{ {
if (version >= 38) LastStrGain = reader.ReadDeltaTime();
{ LastIntGain = reader.ReadDeltaTime();
LastStrGain = reader.ReadAnchoredTime(); LastDexGain = reader.ReadDeltaTime();
LastIntGain = reader.ReadAnchoredTime();
LastDexGain = reader.ReadAnchoredTime();
}
else
{
LastStrGain = reader.ReadDeltaTime();
LastIntGain = reader.ReadDeltaTime();
LastDexGain = reader.ReadDeltaTime();
}
goto case 30; goto case 30;
} }
@ -6495,6 +6461,9 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_DexLock = (StatLockType)reader.ReadByte(); m_DexLock = (StatLockType)reader.ReadByte();
m_IntLock = (StatLockType)reader.ReadByte(); m_IntLock = (StatLockType)reader.ReadByte();
_statMods = new List<StatMod>();
_skillMods = new List<SkillMod>();
if (version < 32) if (version < 32)
{ {
if (reader.ReadBool()) if (reader.ReadBool())
@ -7827,10 +7796,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
m_FollowersMax = 5; m_FollowersMax = 5;
Skills = new Skills(this); Skills = new Skills(this);
Items = new List<Item>(); Items = new List<Item>();
_statMods = new List<StatMod>();
_skillMods = new List<SkillMod>();
Map = Map.Internal; Map = Map.Internal;
AutoPageNotify = true; AutoPageNotify = true;
Aggressors = new List<AggressorInfo>(); Aggressors = new List<AggressorInfo>();
Aggressed = new List<AggressorInfo>(); Aggressed = new List<AggressorInfo>();
DamageEntries = new List<DamageEntry>();
NextSkillTime = Core.TickCount; NextSkillTime = Core.TickCount;
} }
@ -7862,12 +7834,6 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
} }
} }
/// <summary>
/// True when deltas remain queued after a <see cref="ProcessDeltaQueue"/> pass, which is
/// bounded by the count it saw on entry. The event loop consults this before sleeping.
/// </summary>
public static bool HasQueuedDeltas => m_DeltaQueue.Count > 0;
public static void ProcessDeltaQueue() public static void ProcessDeltaQueue()
{ {
var limit = m_DeltaQueue.Count; var limit = m_DeltaQueue.Count;

View file

@ -21,15 +21,17 @@ namespace Server;
[SerializationGenerator(0)] [SerializationGenerator(0)]
public partial class ResistanceMod : MobileMod public partial class ResistanceMod : MobileMod
{ {
[SerializableField(0, fieldChanged: nameof(OnTypeChanged))] [SerializableField(0)]
private ResistanceType _type; private ResistanceType _type;
[SerializableFieldChanged(0)]
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances(); private void OnTypeChanged(ResistanceType oldValue, ResistanceType newValue) => Owner?.UpdateResistances();
[SerializableField(1, fieldChanged: nameof(OnOffsetChanged))] [SerializableField(1)]
private int _offset; private int _offset;
[SerializableFieldChanged(1)]
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances(); private void OnOffsetChanged(int oldValue, int newValue) => Owner?.UpdateResistances();

View file

@ -21,29 +21,33 @@ namespace Server;
[SerializationGenerator(0)] [SerializationGenerator(0)]
public abstract partial class SkillMod : MobileMod public abstract partial class SkillMod : MobileMod
{ {
[SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))] [SerializableField(0)]
private bool _obeyCap; private bool _obeyCap;
[SerializableFieldChanged(0)]
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
[SerializableField(1, fieldChanged: nameof(OnSkillChanged))] [SerializableField(1)]
private SkillName _skill; private SkillName _skill;
[SerializableFieldChanged(1)]
private void OnSkillChanged(SkillName oldValue, SkillName newValue) private void OnSkillChanged(SkillName oldValue, SkillName newValue)
{ {
Owner?.Skills[newValue]?.Update(); Owner?.Skills[newValue]?.Update();
Owner?.Skills[oldValue]?.Update(); Owner?.Skills[oldValue]?.Update();
} }
[SerializableField(2, fieldChanged: nameof(OnRelativeChanged))] [SerializableField(2)]
private bool _relative; private bool _relative;
[SerializableFieldChanged(2)]
private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); private void OnRelativeChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
[SerializableField(3, fieldChanged: nameof(OnValueChanged))] [SerializableField(3)]
private double _value; private double _value;
[SerializableFieldChanged(3)]
private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update(); private void OnValueChanged(double oldValue, double newValue) => Owner?.Skills[_skill]?.Update();
public SkillMod(Mobile owner) : base(owner) public SkillMod(Mobile owner) : base(owner)

View file

@ -50,6 +50,9 @@ public static class MovementThrottle
private const int ClientMaxUnackedMovements = 5; private const int ClientMaxUnackedMovements = 5;
private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4 private const int MaxQueueWithUnmodifiedClient = ClientMaxUnackedMovements - 1; // 4
// Debug logging - enable for testing speed hack detection
private static bool _debugLogging = false;
// Track NetStates with queued movements for efficient processing // Track NetStates with queued movements for efficient processing
private static readonly HashSet<NetState> _netStatesWithQueuedMovements = new(256); private static readonly HashSet<NetState> _netStatesWithQueuedMovements = new(256);
@ -80,9 +83,15 @@ public static class MovementThrottle
public static void Configure() public static void Configure()
{ {
_maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit); _maxCredit = ServerConfiguration.GetOrUpdateSetting(
_maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus); "movementThrottle.maxCredit",
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit); _maxCredit
);
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.hardQueueLimit",
_hardQueueLimit
);
_movementHistorySize = ServerConfiguration.GetOrUpdateSetting( _movementHistorySize = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.movementHistorySize", "movementThrottle.movementHistorySize",
@ -94,13 +103,6 @@ public static class MovementThrottle
_minSamplesForRate _minSamplesForRate
); );
_maxChainGap = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxChainGap", _maxChainGap);
_speedHackNotificationCooldown = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.speedHackNotificationCooldown",
_speedHackNotificationCooldown
);
_suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting( _suspiciousRateThreshold = (float)ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.suspiciousRateThreshold", "movementThrottle.suspiciousRateThreshold",
_suspiciousRateThreshold _suspiciousRateThreshold
@ -110,6 +112,11 @@ public static class MovementThrottle
"movementThrottle.definiteRateThreshold", "movementThrottle.definiteRateThreshold",
_definiteRateThreshold _definiteRateThreshold
); );
_debugLogging = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.debugLogging",
_debugLogging
);
} }
/// <summary> /// <summary>
@ -184,16 +191,15 @@ public static class MovementThrottle
// Credit can go negative up to -dynamicCredit (debt limit) // Credit can go negative up to -dynamicCredit (debt limit)
if (ns._movementCredit - earlyAmount >= -dynamicCredit) if (ns._movementCredit - earlyAmount >= -dynamicCredit)
{ {
var prevCredit = ns._movementCredit;
// Use credit to cover early arrival // Use credit to cover early arrival
ns._movementCredit -= earlyAmount; ns._movementCredit -= earlyAmount;
if (ns._movementLogging) if (_debugLogging && ns._movementLogging)
{ {
var prevCredit = ns._movementCredit + earlyAmount;
logger.Debug( logger.Debug(
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit mobile.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
); );
} }
@ -202,11 +208,11 @@ public static class MovementThrottle
return; return;
} }
if (ns._movementLogging) if (_debugLogging && ns._movementLogging)
{ {
logger.Debug( logger.Debug(
"[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue", "[Credit] {Name}: delta={Delta}ms early={Early}ms credit={Credit}/{MaxCredit} EXHAUSTED -> queue",
mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit mobile.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit
); );
} }
@ -221,11 +227,11 @@ public static class MovementThrottle
var prevCredit = ns._movementCredit; var prevCredit = ns._movementCredit;
ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit); ns._movementCredit = Math.Min(ns._movementCredit + delta, dynamicCredit);
if (ns._movementLogging && ns._movementCredit != prevCredit) if (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit)
{ {
logger.Debug( logger.Debug(
"[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute", "[Credit] {Name}: delta=+{Delta}ms credit={PrevCredit}->{Credit}/{MaxCredit} action=execute",
mobile, delta, prevCredit, ns._movementCredit, dynamicCredit mobile.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit
); );
} }
} }
@ -241,9 +247,12 @@ public static class MovementThrottle
{ {
if (!mobile.Move(dir)) if (!mobile.Move(dir))
{ {
if (ns._movementLogging) if (_debugLogging && ns._movementLogging)
{ {
logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq); logger.Debug(
"[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset",
mobile.RawName, dir, seq
);
} }
// Movement failed (blocked, paralyzed, frozen, etc.) // Movement failed (blocked, paralyzed, frozen, etc.)
@ -251,11 +260,11 @@ public static class MovementThrottle
return; return;
} }
if (ns._movementLogging) if (_debugLogging && ns._movementLogging)
{ {
logger.Debug( logger.Debug(
"[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms", "[Execute] {Name}: Move OK dir={Dir} seq={Seq} nextMove={NextMove}ms",
mobile, dir, seq, ns._nextMovementTime - Core.TickCount mobile.RawName, dir, seq, ns._nextMovementTime - Core.TickCount
); );
} }
@ -295,11 +304,11 @@ public static class MovementThrottle
ns._hasQueuedMovements = true; ns._hasQueuedMovements = true;
_netStatesWithQueuedMovements.Add(ns); _netStatesWithQueuedMovements.Add(ns);
if (ns._movementLogging) if (_debugLogging && ns._movementLogging)
{ {
logger.Debug( logger.Debug(
"[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})", "[Queue] {Name}: enqueued dir={Dir} seq={Seq} (depth={Depth})",
ns.Mobile, dir, seq, ns._movementQueue.Count ns.Mobile?.RawName, dir, seq, ns._movementQueue.Count
); );
} }
} }
@ -311,6 +320,7 @@ public static class MovementThrottle
{ {
ns.SendMovementRej(seq, mobile); ns.SendMovementRej(seq, mobile);
ns.ResetMovementState(); ns.ResetMovementState();
_netStatesWithQueuedMovements.Remove(ns);
} }
/// <summary> /// <summary>
@ -323,18 +333,20 @@ public static class MovementThrottle
return; return;
} }
foreach (var ns in _netStatesWithQueuedMovements) // Process each NetState with queued movements
// Use a snapshot to avoid modification during iteration
var toProcess = new List<NetState>(_netStatesWithQueuedMovements);
for (var i = 0; i < toProcess.Count; i++)
{ {
if (ns.Running) var ns = toProcess[i];
if (!ns.Running)
{ {
ProcessMovementQueue(ns); _netStatesWithQueuedMovements.Remove(ns);
if (ns._hasQueuedMovements) continue;
{
continue;
}
} }
_netStatesWithQueuedMovements.Remove(ns); ProcessMovementQueue(ns);
} }
} }
@ -344,7 +356,6 @@ public static class MovementThrottle
public static void ProcessMovementQueue(NetState ns) public static void ProcessMovementQueue(NetState ns)
{ {
var mobile = ns.Mobile; var mobile = ns.Mobile;
if (mobile?.Deleted != false) if (mobile?.Deleted != false)
{ {
ClearQueue(ns); ClearQueue(ns);
@ -363,7 +374,7 @@ public static class MovementThrottle
while (ns._movementQueue?.Count > 0) while (ns._movementQueue?.Count > 0)
{ {
// Check if it's time to execute // Check if it's time to execute
if (now - ns._nextMovementTime < 0) if (now < ns._nextMovementTime)
{ {
// Not yet - leave remaining items in queue for next Slice // Not yet - leave remaining items in queue for next Slice
break; break;
@ -383,11 +394,11 @@ public static class MovementThrottle
// Execute the move // Execute the move
if (!mobile.Move(movement.Direction)) if (!mobile.Move(movement.Direction))
{ {
if (ns._movementLogging) if (_debugLogging && ns._movementLogging)
{ {
logger.Debug( logger.Debug(
"[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})", "[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})",
mobile, movement.Direction, remaining mobile.RawName, movement.Direction, remaining
); );
} }
@ -396,12 +407,12 @@ public static class MovementThrottle
return; return;
} }
if (ns._movementLogging) if (_debugLogging && ns._movementLogging)
{ {
var waited = now - ns._nextMovementTime; var waited = now - ns._nextMovementTime;
logger.Debug( logger.Debug(
"[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)", "[Queue] {Name}: dequeued OK dir={Dir} (remaining={Remaining}, waited={Waited}ms)",
mobile, movement.Direction, remaining, waited >= 0 ? waited : 0 mobile.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0
); );
} }
@ -419,6 +430,10 @@ public static class MovementThrottle
// Update tracking // Update tracking
ns._hasQueuedMovements = ns._movementQueue?.Count > 0; ns._hasQueuedMovements = ns._movementQueue?.Count > 0;
if (!ns._hasQueuedMovements)
{
_netStatesWithQueuedMovements.Remove(ns);
}
} }
/// <summary> /// <summary>
@ -454,6 +469,7 @@ public static class MovementThrottle
{ {
ns._movementQueue?.Clear(); ns._movementQueue?.Clear();
ns._hasQueuedMovements = false; ns._hasQueuedMovements = false;
_netStatesWithQueuedMovements.Remove(ns);
} }
// Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance) // Maximum expected packets per second (mounted running = 100ms = 10/sec, plus tolerance)
@ -468,7 +484,7 @@ public static class MovementThrottle
logger.Information( logger.Information(
"Movement queue overflow: {Character} ({Account}) | " + "Movement queue overflow: {Character} ({Account}) | " +
"Queue reached hard limit: {Limit} | IP: {IP}", "Queue reached hard limit: {Limit} | IP: {IP}",
mobile, mobile?.RawName ?? "Unknown",
ns.Account?.Username ?? "Unknown", ns.Account?.Username ?? "Unknown",
_hardQueueLimit, _hardQueueLimit,
ns.Address ns.Address
@ -500,7 +516,7 @@ public static class MovementThrottle
private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile) private static void RecordMovement(NetState ns, long now, int cost, Direction dir, Mobile mobile)
{ {
// Calculate interval since last movement // Calculate interval since last movement
var interval = ns._hasMovementRecord var interval = ns._lastMovementRecordTime > 0
? (int)(now - ns._lastMovementRecordTime) ? (int)(now - ns._lastMovementRecordTime)
: -1; // -1 indicates first movement (no previous time) : -1; // -1 indicates first movement (no previous time)
@ -509,7 +525,6 @@ public static class MovementThrottle
if (interval <= 0 || interval > _maxChainGap) if (interval <= 0 || interval > _maxChainGap)
{ {
ns._lastMovementRecordTime = now; ns._lastMovementRecordTime = now;
ns._hasMovementRecord = true;
// Use RTT to distinguish "stopped moving" vs "lagged" // Use RTT to distinguish "stopped moving" vs "lagged"
// - Stable low-latency connection with gap >> RTT → player stopped, reset history // - Stable low-latency connection with gap >> RTT → player stopped, reset history
@ -529,19 +544,19 @@ public static class MovementThrottle
// A large gap followed by a burst of packets = likely lag recovery, not speed hack // A large gap followed by a burst of packets = likely lag recovery, not speed hack
ns._lastGapDuration = interval; ns._lastGapDuration = interval;
if (ns._movementLogging) if (_debugLogging && mobile?.RawName != null)
{ {
var action = shouldReset ? "history reset" : "history preserved (possible lag)"; var action = shouldReset ? "history reset" : "history preserved (possible lag)";
logger.Debug( logger.Debug(
"[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " + "[Movement] {Name}: SKIP recording (gap {Gap}ms > {MaxGap}ms, " +
"RTT={RTT}ms stable={Stable} → {Action})", "RTT={RTT}ms stable={Stable} → {Action})",
mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action mobile.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
); );
} }
} }
else if (ns._movementLogging) else if (_debugLogging && mobile?.RawName != null)
{ {
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile); logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName);
} }
return; return;
@ -557,9 +572,12 @@ public static class MovementThrottle
// the next real move's interval artificially short, inflating rate. // the next real move's interval artificially short, inflating rate.
if (cost == 0) if (cost == 0)
{ {
if (ns._movementLogging) if (_debugLogging && mobile?.RawName != null)
{ {
logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile); logger.Debug(
"[Movement] {Name}: SKIP direction-only change (preserves interval measurement)",
mobile.RawName
);
} }
return; return;
} }
@ -595,16 +613,15 @@ public static class MovementThrottle
} }
ns._lastMovementRecordTime = now; ns._lastMovementRecordTime = now;
ns._hasMovementRecord = true;
// Debug logging // Debug logging
if (ns._movementLogging) if (_debugLogging && mobile?.RawName != null)
{ {
var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex; var historyCount = ns._movementHistoryFull ? _movementHistorySize : ns._movementHistoryIndex;
logger.Debug( logger.Debug(
"[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " + "[Movement] {Name}: interval={Interval}ms target={Target}ms queue={Queue} " +
"flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms", "flags={Flags} history={History}/{MaxHistory} RTT={RTT}ms",
mobile, interval, cost, record.QueueDepth, mobile.RawName, interval, cost, record.QueueDepth,
flags, historyCount, _movementHistorySize, ns.AverageRtt flags, historyCount, _movementHistorySize, ns.AverageRtt
); );
} }
@ -797,7 +814,7 @@ public static class MovementThrottle
var averageRtt = ns.AverageRtt; var averageRtt = ns.AverageRtt;
// Detailed rate breakdown for debugging // Detailed rate breakdown for debugging
if (ns._movementLogging) if (_debugLogging)
{ {
logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms", logger.Debug("[MovementAnalysis] Rate={Rate:F3}, Samples={Samples}, RTT={RTT}ms",
rate, sampleCount, averageRtt); rate, sampleCount, averageRtt);
@ -960,19 +977,19 @@ public static class MovementThrottle
var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence); var verdict = AnalyzeMovement(ns, out var rate, out var sampleCount, out var confidence);
// Debug logging // Debug logging
if (ns._movementLogging) if (_debugLogging && ns.Mobile?.RawName != null)
{ {
var (burstSize, _) = DetectRecentBurst(ns); var (burstSize, _) = DetectRecentBurst(ns);
var probeStatus = ns._rttProbePending ? "pending" : "idle"; var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle";
var queueDepth = ns._movementQueue?.Count ?? 0; var queueDepth = ns._movementQueue?.Count ?? 0;
logger.Debug( logger.Debug(
"[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " + "[RateCheck] {Name}: rate={Rate:F3} samples={Samples} verdict={Verdict} " +
"confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s", "confidence={Confidence:P0} queue={Queue} burst={Burst} sustained={Sustained}s",
ns.Mobile, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds ns.Mobile.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds
); );
logger.Debug( logger.Debug(
" RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}", " RTT: avg={Avg}ms last={Last}ms var={Var} samples={RttSamples} stable={Stable} probe={Probe}",
ns.AverageRtt, ns.LastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus ns.AverageRtt, ns._lastRtt, ns._rttVariance, ns._rttSampleCount, ns.HasStableConnection, probeStatus
); );
} }
@ -1008,11 +1025,11 @@ public static class MovementThrottle
if (shouldNotify) if (shouldNotify)
{ {
if (ns._movementLogging) if (_debugLogging)
{ {
logger.Debug( logger.Debug(
"[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}", "[ALERT] {Urgency} - {Name}: rate={Rate:F3} verdict={Verdict} confidence={Confidence:P0}",
urgency, ns.Mobile, rate, verdict, confidence urgency, ns.Mobile?.RawName, rate, verdict, confidence
); );
} }
NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency); NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency);
@ -1037,12 +1054,11 @@ public static class MovementThrottle
var now = Core.TickCount; var now = Core.TickCount;
// Rate-limit notifications per player // Rate-limit notifications per player
if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
{ {
return; return;
} }
ns._speedHackNotified = true;
ns._lastSpeedHackNotification = now; ns._lastSpeedHackNotification = now;
var mobile = ns.Mobile; var mobile = ns.Mobile;
@ -1054,7 +1070,7 @@ public static class MovementThrottle
"PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " + "PacketRate: {PacketRate}/s (peak: {PeakRate}/s) | RTT: {Rtt}ms (stable: {Stable}) | " +
"Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}", "Sustained: {Sustained}s | Queue: {Queue} | Location: {Location} Map: {Map} | IP: {IP}",
urgency, urgency,
mobile, mobile?.RawName ?? "Unknown",
ns.Account?.Username ?? "Unknown", ns.Account?.Username ?? "Unknown",
rate, rate,
sampleCount, sampleCount,
@ -1122,7 +1138,7 @@ public static class MovementThrottle
Verdict = verdict, Verdict = verdict,
Confidence = confidence, Confidence = confidence,
AverageRtt = ns.AverageRtt, AverageRtt = ns.AverageRtt,
LastRtt = ns.LastRtt, LastRtt = ns._lastRtt,
RttVariance = ns._rttVariance, RttVariance = ns._rttVariance,
StableConnection = ns.HasStableConnection, StableConnection = ns.HasStableConnection,
RttSampleCount = ns._rttSampleCount, RttSampleCount = ns._rttSampleCount,

View file

@ -15,6 +15,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Server.Logging; using Server.Logging;
@ -69,24 +70,23 @@ public partial class NetState
internal Queue<QueuedMovement> _movementQueue; // Lazy initialized internal Queue<QueuedMovement> _movementQueue; // Lazy initialized
internal long _movementCredit; // Credit buffer for timing jitter internal long _movementCredit; // Credit buffer for timing jitter
internal long _nextMovementTime = Core.TickCount; // When next movement is allowed internal long _nextMovementTime = Core.TickCount; // When next movement is allowed
internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency internal int _sustainedQueueDepth; // Tracks sustained high queue depth
internal long _lastQueueDepthCheck; // Throttle depth check frequency
internal bool _hasQueuedMovements; // Fast check for Slice() internal bool _hasQueuedMovements; // Fast check for Slice()
// Movement history for rate-based speed hack detection (lazy initialized) // Movement history for rate-based speed hack detection (lazy initialized)
internal MovementRecord[] _movementHistory; // Circular buffer internal MovementRecord[] _movementHistory; // Circular buffer
internal int _movementHistoryIndex; // Next write position (also serves as count until full) internal int _movementHistoryIndex; // Next write position (also serves as count until full)
internal bool _movementHistoryFull; // True once buffer has wrapped internal bool _movementHistoryFull; // True once buffer has wrapped
internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord) internal long _lastMovementRecordTime; // For calculating intervals
internal bool _hasMovementRecord; // False until the first movement in a chain is seen
// Detection state // Detection state
internal int _consecutiveHighRateSeconds; // Sustained detection counter internal int _consecutiveHighRateSeconds; // Sustained detection counter
internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified) internal long _lastSpeedHackNotification; // Rate-limit notifications
internal bool _speedHackNotified; // False until the first notification is sent
internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness) internal int _lastGapDuration; // Duration of last gap > maxChainGap (for burst forgiveness)
// Movement packet rate tracking (for speed hack detection) // Movement packet rate tracking (for speed hack detection)
internal long _movementWindowStart = Core.TickCount; // Start of current 1-second window internal long _movementWindowStart; // Start of current 1-second window
internal int _movementsInWindow; // Count in current window internal int _movementsInWindow; // Count in current window
internal int _peakMovementRate; // Highest rate seen (packets/sec) internal int _peakMovementRate; // Highest rate seen (packets/sec)
@ -100,9 +100,10 @@ public partial class NetState
_nextMovementTime = Core.TickCount; _nextMovementTime = Core.TickCount;
_movementCredit = 0; _movementCredit = 0;
_hasQueuedMovements = false; _hasQueuedMovements = false;
_sustainedQueueDepth = 0;
// Reset movement history - next movement starts a new chain // Reset movement history - next movement starts a new chain
_hasMovementRecord = false; _lastMovementRecordTime = 0;
_movementHistoryIndex = 0; _movementHistoryIndex = 0;
_movementHistoryFull = false; _movementHistoryFull = false;
@ -112,7 +113,7 @@ public partial class NetState
_rttProbeInterval = RttProbeIntervalNormal; _rttProbeInterval = RttProbeIntervalNormal;
// Reset packet rate window // Reset packet rate window
_movementWindowStart = Core.TickCount; _movementWindowStart = 0;
_movementsInWindow = 0; _movementsInWindow = 0;
} }
@ -164,19 +165,17 @@ public partial class NetState
private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection private const long MaxStableLatency = 200; // Max RTT (ms) for "stable" connection
// RTT state // RTT state
internal bool _rttProbePending; // True while waiting for a probe response internal long _rttProbeTime; // When we sent the probe (0 = not waiting)
internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending) internal long _lastRtt; // Most recent RTT measurement
internal long[] _rttHistory; // Rolling history (lazy init) internal long[] _rttHistory; // Rolling history (lazy init)
internal int _rttHistoryIndex; // Current position in history internal int _rttHistoryIndex; // Current position in history
internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize) internal int _rttSampleCount; // Number of samples collected (saturates at RttHistorySize)
internal long _rttVariance; // Calculated variance for stability internal long _rttVariance; // Calculated variance for stability
internal long _nextRttProbe = Core.TickCount; // When to send next probe internal long _nextRttProbe; // When to send next probe
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
/// <summary> // High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks)
/// Gets the most recent RTT measurement, or 0 if none has been recorded. private long _rttProbeTimestampHiRes;
/// </summary>
public long LastRtt => _rttSampleCount > 0 ? _rttHistory[(_rttHistoryIndex - 1) & (RttHistorySize - 1)] : 0;
/// <summary> /// <summary>
/// Sets the RTT probe interval based on suspicion level. /// Sets the RTT probe interval based on suspicion level.
@ -207,22 +206,23 @@ public partial class NetState
var now = Core.TickCount; var now = Core.TickCount;
// Don't send if we're still waiting for a response // Don't send if we're still waiting for a response
if (_rttProbePending) if (_rttProbeTime > 0)
{ {
// Timeout after 10 seconds - connection is probably dead or very laggy // Timeout after 10 seconds - connection is probably dead or very laggy
if (now - _rttProbeTime > 10000) if (now - _rttProbeTime > 10000)
{ {
_rttProbePending = false; _rttProbeTime = 0;
_rttProbeTimestampHiRes = 0;
} }
return; return;
} }
// First probe: send immediately when player starts moving // First probe: send immediately when player starts moving
// Subsequent probes: send when interval has passed // Subsequent probes: send when interval has passed
if (now - _nextRttProbe >= 0) if (_nextRttProbe == 0 || now >= _nextRttProbe)
{ {
_rttProbePending = true;
_rttProbeTime = now; _rttProbeTime = now;
_rttProbeTimestampHiRes = Stopwatch.GetTimestamp();
_nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter); _nextRttProbe = now + _rttProbeInterval + Utility.Random(RttProbeJitter);
if (_movementLogging) if (_movementLogging)
@ -242,9 +242,10 @@ public partial class NetState
/// </summary> /// </summary>
public void RecordRttMeasurement() public void RecordRttMeasurement()
{ {
var nowHiRes = Stopwatch.GetTimestamp();
var now = Core.TickCount; var now = Core.TickCount;
if (!_rttProbePending) if (_rttProbeTime <= 0)
{ {
// Not expecting a response (client-initiated version send) - ignore silently // Not expecting a response (client-initiated version send) - ignore silently
return; return;
@ -252,15 +253,19 @@ public partial class NetState
var rtt = now - _rttProbeTime; var rtt = now - _rttProbeTime;
// High-resolution RTT in microseconds
var rttHiResUs = (nowHiRes - _rttProbeTimestampHiRes) * 1_000_000 / Stopwatch.Frequency;
if (_movementLogging) if (_movementLogging)
{ {
movementLogger.Debug( movementLogger.Debug(
"[RTT-Response] {Account}: {Rtt}ms", "[RTT-Response] {Account}: {Rtt}ms (HiRes: {RttHiRes:F2}ms)",
Account?.Username ?? _toString, rtt Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0
); );
} }
_rttProbePending = false; _rttProbeTime = 0;
_rttProbeTimestampHiRes = 0;
// Sanity check - RTT should be positive and reasonable // Sanity check - RTT should be positive and reasonable
if (rtt is <= 0 or > 10000) if (rtt is <= 0 or > 10000)
@ -280,6 +285,7 @@ public partial class NetState
// Update history // Update history
_rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt; _rttHistory[_rttHistoryIndex++ & (RttHistorySize - 1)] = rtt;
_lastRtt = rtt;
// Track sample count (saturates at buffer size) // Track sample count (saturates at buffer size)
if (_rttSampleCount < RttHistorySize) if (_rttSampleCount < RttHistorySize)

View file

@ -71,24 +71,6 @@ public partial class NetState
_socketManager?.WaitForCompletion(timeoutMs); _socketManager?.WaitForCompletion(timeoutMs);
} }
/// <summary>
/// Wakes the game loop if it is blocked in <see cref="WaitForCompletion"/>. 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.
/// </summary>
public static void Wake()
{
_socketManager?.Ring?.Wake();
}
/// <summary>
/// True when no queued network work remains for the loop to drain. <see cref="Slice"/> defers
/// work in several places, so an empty completion queue alone is not enough.
/// </summary>
internal static bool IsIdle =>
_throttled.Count == 0 && _throttledPending.Count == 0 &&
_flushPending.Count == 0 && _pendingDisconnects.Count == 0 && _disposed.Count == 0;
/// <summary> /// <summary>
/// Gets the listening addresses that the server is bound to. /// Gets the listening addresses that the server is bound to.
/// </summary> /// </summary>

View file

@ -44,7 +44,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
private static readonly Queue<NetState> _connectingQueue = new(2048); private static readonly Queue<NetState> _connectingQueue = new(2048);
private static readonly HashSet<NetState> _instances = new(2048); private static readonly HashSet<NetState> _instances = new(2048);
public static HashSet<NetState> Instances => _instances; public static IReadOnlySet<NetState> Instances => _instances;
private readonly string _toString; private readonly string _toString;
private ClientVersion _version; private ClientVersion _version;
@ -109,6 +109,9 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
Address = address; Address = address;
Seeded = false; Seeded = false;
HuePickers = [];
Menus = [];
Trades = [];
NextActivityCheck = Core.TickCount + 30000; NextActivityCheck = Core.TickCount + 30000;
ConnectedOn = Core.Now; ConnectedOn = Core.Now;
_toString = address?.ToString() ?? "(error)"; _toString = address?.ToString() ?? "(error)";
@ -163,7 +166,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public bool BlockAllPackets { get; set; } public bool BlockAllPackets { get; set; }
public List<SecureTrade> Trades { get; private set; } public List<SecureTrade> Trades { get; }
public bool Seeded { get; set; } public bool Seeded { get; set; }
@ -257,18 +260,8 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public void ValidateAllTrades() public void ValidateAllTrades()
{ {
if (Trades == null)
{
return;
}
for (var i = Trades.Count - 1; i >= 0; --i) for (var i = Trades.Count - 1; i >= 0; --i)
{ {
if (Trades == null)
{
break;
}
if (i >= Trades.Count) if (i >= Trades.Count)
{ {
continue; continue;
@ -287,18 +280,8 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public void CancelAllTrades() public void CancelAllTrades()
{ {
if (Trades == null)
{
return;
}
for (var i = Trades.Count - 1; i >= 0; --i) for (var i = Trades.Count - 1; i >= 0; --i)
{ {
if (Trades != null)
{
break;
}
if (i < Trades.Count) if (i < Trades.Count)
{ {
Trades[i].Cancel(); Trades[i].Cancel();
@ -308,21 +291,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public void RemoveTrade(SecureTrade trade) public void RemoveTrade(SecureTrade trade)
{ {
Trades?.Remove(trade); Trades.Remove(trade);
if (Trades?.Count == 0)
{
Trades = null;
}
} }
public SecureTrade FindTrade(Mobile m) public SecureTrade FindTrade(Mobile m)
{ {
if (Trades == null)
{
return null;
}
for (var i = 0; i < Trades.Count; ++i) for (var i = 0; i < Trades.Count; ++i)
{ {
var trade = Trades[i]; var trade = Trades[i];
@ -338,11 +311,6 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public SecureTradeContainer FindTradeContainer(Mobile m) public SecureTradeContainer FindTradeContainer(Mobile m)
{ {
if (Trades == null)
{
return null;
}
for (var i = 0; i < Trades.Count; ++i) for (var i = 0; i < Trades.Count; ++i)
{ {
var trade = Trades[i]; var trade = Trades[i];
@ -368,11 +336,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
{ {
var newTrade = new SecureTrade(Mobile, state.Mobile); var newTrade = new SecureTrade(Mobile, state.Mobile);
Trades ??= [];
Trades.Add(newTrade); Trades.Add(newTrade);
state.Trades ??= [];
state.Trades.Add(newTrade); state.Trades.Add(newTrade);
return newTrade.From.Container; return newTrade.From.Container;
@ -1212,16 +1176,8 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
var a = Account; var a = Account;
Menus?.Clear(); Menus.Clear();
Menus = null; HuePickers.Clear();
HuePickers?.Clear();
HuePickers = null;
// Just in case, but should already be nulled when Mobile.NetState is set to null and CancelAllTrades is called.
Trades?.Clear();
Trades = null;
Account = null; Account = null;
ServerInfo = null; ServerInfo = null;
CityInfo = null; CityInfo = null;

View file

@ -74,12 +74,6 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader
/// </summary> /// </summary>
public long Position => _reader.Position; public long Position => _reader.Position;
public TimeSpan AnchoredTimeShift
{
get => _reader.AnchoredTimeShift;
set => _reader.AnchoredTimeShift = value;
}
public void Dispose() public void Dispose()
{ {
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer(); _accessor?.SafeMemoryMappedViewHandle.ReleasePointer();

View file

@ -37,8 +37,6 @@ public class BufferReader : IGenericReader
public long Position => _position; public long Position => _position;
public long BufferSize => _buffer.Length; public long BufferSize => _buffer.Length;
public TimeSpan AnchoredTimeShift { get; set; }
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null) public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
{ {
_buffer = buffer; _buffer = buffer;

View file

@ -384,7 +384,6 @@ public class BufferWriter : IGenericWriter
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [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) public void WriteDeltaTime(DateTime value)
{ {
if (value == DateTime.MinValue) if (value == DateTime.MinValue)
@ -408,21 +407,6 @@ public class BufferWriter : IGenericWriter
Write(value.Ticks - DateTime.UtcNow.Ticks); Write(value.Ticks - DateTime.UtcNow.Ticks);
} }
/// <summary>
/// Writes the absolute value; <see cref="IGenericReader.ReadAnchoredTime" /> 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.
/// </summary>
public void WriteAnchoredTime(DateTime value)
{
if (value.Kind == DateTimeKind.Local)
{
value = value.ToUniversalTime();
}
Write(value.Ticks);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Write(IPAddress value) public void Write(IPAddress value)
{ {

View file

@ -114,10 +114,9 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
using var binFs = new FileStream( using var binFs = new FileStream(
Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024 Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024
); );
// v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor // v4 records are fixed-width 26 bytes; the header carries the type table
// and the type table (name lengths vary — 64 bytes per entry is a staging hint, not // (name lengths vary — 64 bytes per entry is a staging hint, not a contract).
// a contract). var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize); using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
var binPosition = 0L; var binPosition = 0L;
@ -143,10 +142,7 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
binPosition += _selfLength; binPosition += _selfLength;
} }
idx.Write(5); // Version idx.Write(4); // 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 // 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 // queues while saving) and is written before the records so the loader can
@ -498,18 +494,6 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
var version = dataReader.ReadInt(); 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) if (version >= 4)
{ {
DeserializeIndexesV4(dataReader, entities); DeserializeIndexesV4(dataReader, entities);
@ -676,9 +660,6 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
private static List<T> _toDelete; private static List<T> _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<ulong, string> typesDb) private unsafe void InternalDeserialize(string filePath, int index, Dictionary<ulong, string> typesDb)
{ {
using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open); using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open);
@ -686,10 +667,7 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
byte* ptr = null; byte* ptr = null;
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); 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); Deserialize(dataReader);

View file

@ -98,13 +98,7 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable
byte* ptr = null; byte* ptr = null;
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); 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); Deserialize(dataReader);
error = dataReader.Position != fileLength error = dataReader.Position != fileLength

View file

@ -43,12 +43,6 @@ public interface IGenericReader
DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc); DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc);
TimeSpan ReadTimeSpan() => new(ReadLong()); TimeSpan ReadTimeSpan() => new(ReadLong());
/// <summary>
/// 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 <see cref="ReadAnchoredTime" />. <see cref="IGenericWriter.WriteDeltaTime" /> is
/// obsolete: no current-version format may write delta time.
/// </summary>
DateTime ReadDeltaTime() DateTime ReadDeltaTime()
{ {
return ReadLong() switch return ReadLong() switch
@ -58,37 +52,6 @@ public interface IGenericReader
var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc) var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc)
}; };
} }
/// <summary>
/// Elapsed time between the loaded save starting and this load, applied by
/// <see cref="ReadAnchoredTime" />. Zero when the source carries no anchor.
/// </summary>
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()]); decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]);
int ReadEncodedInt() int ReadEncodedInt()
{ {

View file

@ -40,11 +40,7 @@ public interface IGenericWriter
void Write(decimal value); void Write(decimal value);
void WriteEncodedInt(int value); void WriteEncodedInt(int value);
void Write(DateTime 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 WriteDeltaTime(DateTime value);
void WriteAnchoredTime(DateTime value);
void Write(IPAddress value); void Write(IPAddress value);
void Write(TimeSpan value); void Write(TimeSpan value);
void Write(Point3D value); void Write(Point3D value);

View file

@ -43,8 +43,6 @@ public unsafe class UnmanagedDataReader : IGenericReader
/// </summary> /// </summary>
public long Position { get; private set; } public long Position { get; private set; }
public TimeSpan AnchoredTimeShift { get; set; }
/// <summary> /// <summary>
/// Read bits of data raw from a serialized file using Little-endian. /// Read bits of data raw from a serialized file using Little-endian.
/// </summary> /// </summary>

View file

@ -34,13 +34,14 @@
</Target> </Target>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Logger\Logger.csproj" /> <ProjectReference Include="..\Logger\Logger.csproj" />
<PackageReference Include="IORingGroup" Version="1.0.10" /> <PackageReference Include="IORingGroup" Version="1.0.9" />
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
<PackageReference Include="LibDeflate.Bindings" Version="1.0.4" /> <PackageReference Include="LibDeflate.Bindings" Version="1.0.3" />
<PackageReference Include="System.IO.Hashing" Version="10.0.11" /> <PackageReference Include="System.IO.Hashing" Version="10.0.10" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="4.0.0" /> <PackageReference Include="ModernUO.Serialization.Annotations" Version="2.14.2" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="4.0.0" PrivateAssets="all" /> <PackageReference Include="ModernUO.Serialization.Generator" Version="2.14.3" />
<PackageReference Update="Serilog" Version="4.4.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="Migrations/*.v*.json" /> <AdditionalFiles Include="Migrations/*.v*.json" />

View file

@ -51,15 +51,8 @@ public partial class Timer
} }
} }
/// <summary>
/// Milliseconds of simulated time one wheel turn advances.
/// </summary>
public static int TickRate => _tickRate;
public static void Slice(long tickCount) public static void Slice(long tickCount)
{ {
EventLoopProfiler.WheelSlice(tickCount - _lastTickTurned);
var deltaSinceTurn = tickCount - _lastTickTurned; var deltaSinceTurn = tickCount - _lastTickTurned;
while (deltaSinceTurn >= _tickRate) while (deltaSinceTurn >= _tickRate)
{ {

View file

@ -1050,16 +1050,28 @@ public static partial class Utility
return; return;
} }
using var queue = PooledRefQueue<K>.Create();
foreach (var (key, value) in dictionary) foreach (var (key, value) in dictionary)
{ {
var deleted = serializableKey if (serializableKey)
? ((ISerializable)key).Deleted
: value == null || ((ISerializable)value).Deleted;
if (deleted)
{ {
dictionary.Remove(key); if (key == null || ((ISerializable)key).Deleted)
{
queue.Enqueue(key);
}
} }
else
{
if (value == null || ((ISerializable)value).Deleted)
{
queue.Enqueue(key);
}
}
}
while (queue.Count > 0)
{
dictionary.Remove(queue.Dequeue());
} }
dictionary.TrimExcess(); dictionary.TrimExcess();

View file

@ -93,21 +93,6 @@ public static class World
public static string SavePath { get; private set; } public static string SavePath { get; private set; }
public static WorldState WorldState { get; private set; } public static WorldState WorldState { get; private set; }
public static bool Saving => WorldState == WorldState.Saving; public static bool Saving => WorldState == WorldState.Saving;
/// <summary>
/// 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.
/// </summary>
public static DateTime SaveStartTime { get; internal set; }
/// <summary>
/// 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 <see cref="SaveStartTime" />) and applied
/// to every reader of that save's files — including <see cref="GenericPersistence" />
/// payloads, which carry no anchor of their own. Zero for saves that predate the anchor.
/// </summary>
public static TimeSpan LoadTimeShift { get; internal set; }
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
public static bool Loading => WorldState == WorldState.Loading; public static bool Loading => WorldState == WorldState.Loading;
@ -302,10 +287,6 @@ public static class World
WorldState = WorldState.Saving; 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."); Broadcast(0x35, true, "The world is saving, please wait.");
logger.Information("Saving world"); logger.Information("Saving world");

View file

@ -100,10 +100,7 @@ internal static class TestServerInitializer
} }
World.Configure(); World.Configure();
// Registers the Accounts entity persistence; without it no test can construct an Account.
Server.Accounting.Accounts.Configure();
RaceDefinitions.Configure(); RaceDefinitions.Configure();
Server.Movement.Movement.Configure();
MovementImpl.Configure(); MovementImpl.Configure();
PathFollower.Configure(); PathFollower.Configure();
World.Load(); World.Load();

View file

@ -1,74 +0,0 @@
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);
}
}

View file

@ -1,229 +0,0 @@
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);
/// <summary>
/// Enqueues work, then pumps the loop context until <paramref name="complete"/> or the deadline.
///
/// The context pins itself to the thread that constructed it and refuses <c>ExecuteTasks</c>
/// 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.
/// </summary>
private static void PumpUntil(Action enqueue, Func<bool> 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
};
/// <summary>
/// Drives the real queue rather than <c>ComputeInline</c>. 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
}

View file

@ -74,89 +74,4 @@ public class PasswordProtectionTest
Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password")); Assert.False(passwordProtection.ValidatePassword(encryptedPassword, "Not the same password"));
} }
/// <summary>
/// Literal digests of <see cref="plainPassword"/>, 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.
/// </summary>
[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"));
}
} }

View file

@ -6,8 +6,6 @@ public class DynamicTestGump : DynamicGump
{ {
private readonly string _petName; private readonly string _petName;
public bool HasVisualElementsForTest => HasVisualElements;
public DynamicTestGump(string petName) : base(50, 50) public DynamicTestGump(string petName) : base(50, 50)
{ {
_petName = petName; _petName = petName;

View file

@ -1,40 +0,0 @@
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<EmptyStaticTestGump>
{
public bool HasVisualElementsForTest => HasVisualElements;
public EmptyStaticTestGump() : base(0, 0)
{
}
protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.SetNoClose();
}
}

View file

@ -4,8 +4,6 @@ namespace Server.Tests.Gumps;
public sealed class LegacyTestGump : Gump public sealed class LegacyTestGump : Gump
{ {
public bool HasVisualElementsForTest => HasVisualElements;
public LegacyTestGump(string petName) : base(50, 50) public LegacyTestGump(string petName) : base(50, 50)
{ {
Serial = (Serial)0x123; Serial = (Serial)0x123;

View file

@ -4,8 +4,6 @@ namespace Server.Tests.Gumps;
public class StaticTestGump : StaticGump<StaticTestGump> public class StaticTestGump : StaticGump<StaticTestGump>
{ {
public bool HasVisualElementsForTest => HasVisualElements;
public StaticTestGump() : base(50, 50) public StaticTestGump() : base(50, 50)
{ {
Serial = (Serial)0x123; Serial = (Serial)0x123;

View file

@ -73,32 +73,6 @@ public class TestLayoutGumps
AssertThat.Equal(writer.Span, packet); 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>(T gump) where T : BaseGump
{
var buffer = GC.AllocateUninitializedArray<byte>(512);
var writer = new SpanWriter(buffer);
gump.Compile(ref writer);
return gump;
}
private static void InternalTestStaticGump<T>(ReadOnlySpan<byte> expectedLayout, StaticGump<T> staticGump, string[] strings) private static void InternalTestStaticGump<T>(ReadOnlySpan<byte> expectedLayout, StaticGump<T> staticGump, string[] strings)
where T : StaticGump<T> where T : StaticGump<T>
{ {

View file

@ -1,111 +0,0 @@
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;
}
}

View file

@ -1,211 +0,0 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Pins the reacquire gate and the AcquireOnApproachDelay gradient: every scan re-arms the
// full ReacquireDelay; enemy movement clamps the deadline to the approach delay (Zero =
// prodded scan); an illegal deadline self-heals.
[Collection("Sequential Pathfinding Tests")]
public class AcquisitionTests : IDisposable
{
private readonly List<Mobile> _created = new();
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
private sealed class WildStub : BaseCreature
{
public WildStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
}
private sealed class TargetStub : Mobile
{
public TargetStub() => Body = 0x190;
}
private WildStub Spawn(Map map, Point3D loc)
{
var bc = new WildStub();
bc.MoveToWorld(loc, map);
bc.AIObject.AITimer?.Stop();
_created.Add(bc);
return bc;
}
[Fact]
public void EmptyScan_HonorsReacquireDelay()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
bc.NextReacquireTime = Core.TickCount;
Assert.False(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
Assert.InRange(bc.NextReacquireTime - Core.TickCount, 5000, 10000);
}
[Fact]
public void WedgedGate_SelfHeals()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
var target = new TargetStub();
target.DefaultMobileInit();
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
_created.Add(target);
// Illegal deadline (beyond ReacquireDelay): must read as open, not block forever.
bc.NextReacquireTime = Core.TickCount + 60000;
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
Assert.Equal(target, bc.FocusMob);
}
[Theory]
[InlineData(false, 5, true)] // an enemy moving inside approach range (10) clamps the deadline
[InlineData(true, 5, false)] // a same-team wild creature is not an enemy — ignored
[InlineData(false, 12, false)] // inside RangePerception but outside approach range — poll only
[InlineData(false, 20, false)] // outside approach range (10) is ignored
public void MovementClampsScanDeadlineOnlyForEnemiesInRange(bool wildMover, int distance, bool notices)
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
bc.NextReacquireTime = Core.TickCount + 8000;
Mobile mover;
if (wildMover)
{
mover = Spawn(map, new Point3D(1500 - distance, 1600, (sbyte)z));
}
else
{
mover = new TargetStub { Player = true };
mover.DefaultMobileInit();
mover.MoveToWorld(new Point3D(1500 - distance, 1600, (sbyte)z), map);
_created.Add(mover);
}
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
var remaining = bc.NextReacquireTime - Core.TickCount;
if (notices)
{
// Clamped to the approach delay (2s), never opened outright.
Assert.InRange(remaining, 1, (long)bc.AcquireOnApproachDelay.TotalMilliseconds);
}
else
{
Assert.True(remaining > 5000);
}
}
private sealed class InstantStub : BaseCreature
{
public InstantStub() : base(AIType.AI_Melee, FightMode.Closest, 16, 1) => Body = 0xC9;
public override TimeSpan AcquireOnApproachDelay => TimeSpan.Zero;
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
}
[Fact]
public void ZeroApproachDelay_OpensGateImmediately()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = new InstantStub();
bc.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
bc.AIObject.AITimer?.Stop();
_created.Add(bc);
bc.NextReacquireTime = Core.TickCount + 8000;
var mover = new TargetStub { Player = true };
mover.DefaultMobileInit();
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
_created.Add(mover);
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
// Zero = the gate opens and the AI is prodded to think now; no direct engage.
Assert.True(Core.TickCount - bc.NextReacquireTime >= 0);
Assert.Null(bc.Combatant);
Assert.True(bc.AIObject.AITimer.Running);
}
[Fact]
public void RepeatedMovement_DoesNotShortenBelowApproachDelay()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
bc.NextReacquireTime = Core.TickCount + 8000;
var mover = new TargetStub { Player = true };
mover.DefaultMobileInit();
mover.MoveToWorld(new Point3D(1495, 1600, (sbyte)z), map);
_created.Add(mover);
bc.OnMovement(mover, new Point3D(1400, 1600, (sbyte)z));
var afterFirst = bc.NextReacquireTime;
bc.OnMovement(mover, new Point3D(1496, 1600, (sbyte)z));
Assert.Equal(afterFirst, bc.NextReacquireTime);
}
[Fact]
public void SuccessfulAcquire_HoldsFullDelay()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var bc = Spawn(map, new Point3D(1500, 1600, (sbyte)z));
var target = new TargetStub();
target.DefaultMobileInit();
target.MoveToWorld(new Point3D(1497, 1600, (sbyte)z), map);
_created.Add(target);
bc.NextReacquireTime = Core.TickCount;
Assert.True(bc.AIObject.AcquireFocusMob(bc.RangePerception, FightMode.Closest, false, false, true));
Assert.Equal(target, bc.FocusMob);
Assert.True(bc.NextReacquireTime - Core.TickCount > 5000);
}
}

View file

@ -40,7 +40,7 @@ public class ApproachTargetTests
for (var i = 0; i < maxTicks; i++) for (var i = 0; i < maxTicks; i++)
{ {
ai.NextMove = 0; ai.NextMove = 0;
ai.WalkMobileRange(target, 1, 1, 2); ai.WalkMobileRange(target, 1, false, 1, 2);
if (bc.InRange(target, arriveDist)) if (bc.InRange(target, arriveDist))
{ {
return true; return true;
@ -123,7 +123,7 @@ public class ApproachTargetTests
for (var i = 0; i < 200; i++) for (var i = 0; i < 200; i++)
{ {
ai.NextMove = 0; ai.NextMove = 0;
ai.MoveTo(target, 1); ai.MoveTo(target, false, 1);
if (bc.InRange(target, 1)) if (bc.InRange(target, 1))
{ {
arrived = true; arrived = true;
@ -154,7 +154,7 @@ public class ApproachTargetTests
for (var i = 0; i < 60; i++) for (var i = 0; i < 60; i++)
{ {
ai.NextMove = 0; ai.NextMove = 0;
ai.MoveTo(target, 1); ai.MoveTo(target, true, 1);
// Target walks west every other tick for its first several steps, then stops, // Target walks west every other tick for its first several steps, then stops,
// so a same-speed chaser eventually closes the gap. // so a same-speed chaser eventually closes the gap.
@ -214,7 +214,7 @@ public class ApproachTargetTests
for (var i = 0; i < 120; i++) for (var i = 0; i < 120; i++)
{ {
ai.NextMove = 0; ai.NextMove = 0;
ai.MoveTo(target, 1); ai.MoveTo(target, false, 1);
} }
// After giving up, the creature must idle (not oscillate) while the goal is still. // After giving up, the creature must idle (not oscillate) while the goal is still.
@ -223,7 +223,7 @@ public class ApproachTargetTests
for (var i = 0; i < 20; i++) for (var i = 0; i < 20; i++)
{ {
ai.NextMove = 0; ai.NextMove = 0;
ai.MoveTo(target, 1); ai.MoveTo(target, false, 1);
if (bc.Location != idleStart) if (bc.Location != idleStart)
{ {
stayedIdle = false; stayedIdle = false;

View file

@ -1,96 +0,0 @@
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Guard-following may pathfind, so this shares the pathfinding collection.
[Collection("Sequential Pathfinding Tests")]
public class GuardFollowTests
{
[Fact]
public void GuardFollow_StepsTowardMaster_AndRegistersMoveIntent()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map); // 6 tiles east, open terrain
pet.SetControlMaster(master);
var ai = pet.AIObject;
ai.AITimer?.Stop(); // drive manually
pet.ControlOrder = OrderType.Guard;
ai.AITimer?.Stop(); // the order change may restart the timer
var start = pet.Location;
ai.NextMove = 0;
ai.Obey();
var moved = pet.Location != start;
var hasIntent = ai.TryGetMoveWake(out _);
var currentSpeed = pet.CurrentSpeed;
var currentMoveSpeed = pet.CurrentMoveSpeed;
pet.Delete();
master.Delete();
Assert.True(moved, "a guarding pet beyond guard range must step toward its master");
// Without a move intent, guard-following only steps on the think grid.
Assert.True(hasIntent, "guard-following must register a move intent");
// AOS return sprint on both clocks; the per-step speed flip must not undo it.
Assert.Equal(0.1, currentSpeed);
Assert.Equal(0.1, currentMoveSpeed);
}
[Fact]
public void GuardReturn_PreAOS_RunsActive()
{
var previous = Core.Expansion;
try
{
Core.Expansion = Expansion.UOR;
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1494, 1600, (sbyte)z), map);
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
pet.SetControlMaster(master);
var ai = pet.AIObject;
ai.AITimer?.Stop();
pet.ControlOrder = OrderType.Guard;
ai.AITimer?.Stop();
pet.SetCurrentSpeedToPassive(); // a stale passive state must not persist
ai.NextMove = 0;
ai.Obey();
var currentSpeed = pet.CurrentSpeed;
pet.Delete();
master.Delete();
// No sprint pre-AOS: the return runs active.
Assert.Equal(0.2, currentSpeed);
}
finally
{
Core.Expansion = previous;
}
}
}

View file

@ -1,137 +0,0 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// A guarding pet fights without leaving the Guard order, retargets toward the master's
// closest aggressor, and stands down when nothing threatens. Scene: the open
// (1495..1500, 1600) Trammel segment; targets are adjacent so no pathfinding runs.
[Collection("Sequential UOContent Tests")]
public class GuardOrderTests : IDisposable
{
private readonly List<Mobile> _created = new();
private sealed class AggressorStub : Mobile
{
public AggressorStub() => Body = 0xC9;
}
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
private (PlayerMobile master, PetTestStub pet) SpawnGuardingPet(out Map map, out int z)
{
map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out z, out _);
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
_created.Add(master);
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1499, 1600, (sbyte)z), map);
pet.SetControlMaster(master);
_created.Add(pet);
pet.AIObject.AITimer?.Stop(); // drive manually
pet.ControlOrder = OrderType.Guard;
pet.AIObject.AITimer?.Stop(); // the order change restarts the timer
return (master, pet);
}
private AggressorStub SpawnAggressor(PetTestStub pet, Point3D loc, Mobile attacking)
{
var aggr = new AggressorStub();
aggr.MoveToWorld(loc, pet.Map);
_created.Add(aggr);
// Setup guard: the scene must stay LOS-clear and the combatant must not be vetoed.
Assert.True(pet.InLOS(aggr), $"no LOS from pet to aggressor at {loc}");
if (attacking != null)
{
aggr.Combatant = attacking;
Assert.Same(attacking, aggr.Combatant);
}
return aggr;
}
[Fact]
public void GuardEngage_KeepsGuardOrder()
{
var (master, pet) = SpawnGuardingPet(out _, out var z);
var aggr = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
pet.AIObject.Obey();
Assert.Same(aggr, pet.Combatant);
Assert.Equal(OrderType.Guard, pet.ControlOrder);
Assert.Equal(OrderType.Guard, pet.AIObject.PersistentOrder);
}
[Fact]
public void Guard_RetargetsToAggressorClosestToMaster()
{
var (master, pet) = SpawnGuardingPet(out _, out var z);
var far = SpawnAggressor(pet, new Point3D(1495, 1600, (sbyte)z), master);
var near = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), master);
pet.Combatant = far; // already fighting the far aggressor
pet.AIObject.Obey();
Assert.Same(near, pet.Combatant); // defends the master, not the current fight
Assert.Equal(OrderType.Guard, pet.ControlOrder);
}
[Fact]
public void ExplicitAttack_ResumesGuard_WithoutChainingIntoAttack()
{
var (master, pet) = SpawnGuardingPet(out _, out var z);
// Explicit kill order on a target that then becomes invalid.
var victim = SpawnAggressor(pet, new Point3D(1498, 1600, (sbyte)z), null);
pet.ControlTarget = victim;
pet.ControlOrder = OrderType.Attack;
victim.Hidden = true;
// A second aggressor is still after the master; FightMode.Closest would chain it.
var aggr2 = SpawnAggressor(pet, new Point3D(1497, 1600, (sbyte)z), master);
pet.AIObject.Obey(); // attack completes -> resume the persistent Guard
Assert.Equal(OrderType.Guard, pet.ControlOrder);
pet.AIObject.Obey(); // the guard scan engages the remaining aggressor in-order
Assert.Same(aggr2, pet.Combatant);
Assert.Equal(OrderType.Guard, pet.ControlOrder);
}
[Fact]
public void PeacefulGuard_StandsDown()
{
var (_, pet) = SpawnGuardingPet(out _, out _);
Assert.True(pet.Warmode); // the guard order opens in war stance
pet.AIObject.Obey(); // nothing to guard against
Assert.False(pet.Warmode);
Assert.Null(pet.Combatant);
Assert.Null(pet.FocusMob);
}
}

View file

@ -1,219 +0,0 @@
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<Mobile> _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);
}
}

View file

@ -1,222 +0,0 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Pet order handlers own the speed clocks; combat chases and herding keep their own pacing.
[Collection("Sequential UOContent Tests")]
public class PetPacingTests : IDisposable
{
private readonly List<Mobile> _created = new();
private (PlayerMobile master, PetTestStub pet) Spawn(Point3D masterLoc, Point3D petLoc)
{
var pair = PetTestSetup.SpawnControlledPet(masterLoc, petLoc);
_created.Add(pair.master);
_created.Add(pair.pet);
return pair;
}
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
// Movement orders run active, resting orders run passive; the move clock follows.
[Fact]
public void OrderIssue_SetsThinkClock()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.SetCurrentSpeedToPassive();
pet.ControlOrder = OrderType.Come;
Assert.Equal(0.2, pet.CurrentSpeed);
Assert.Equal(0.3, pet.CurrentMoveSpeed); // verbatim active -> activeMove
pet.ControlOrder = OrderType.Stay;
Assert.Equal(0.4, pet.CurrentSpeed);
Assert.Equal(0.9, pet.CurrentMoveSpeed);
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
Assert.Equal(0.2, pet.CurrentSpeed);
pet.ControlOrder = OrderType.Guard;
Assert.Equal(0.2, pet.CurrentSpeed);
}
// AOS: following the master sprints at a bespoke 0.1 on both clocks.
[Fact]
public void FollowMaster_ObeySprints()
{
var (master, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.AIObject.AITimer?.Stop();
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow; // fixture era is EJ
pet.AIObject.Obey();
Assert.Equal(0.1, pet.CurrentSpeed);
Assert.Equal(0.1, pet.CurrentMoveSpeed);
}
// At the master's side a guarding pet stays active: no stale-warmode passive, no sprint.
[Fact]
public void GuardAtMastersSide_IsActive()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.3, 0.9);
pet.AIObject.AITimer?.Stop();
pet.SetCurrentSpeedToPassive();
pet.ControlOrder = OrderType.Guard;
pet.AIObject.Obey(); // nothing to guard against, master adjacent
Assert.Equal(0.2, pet.CurrentSpeed);
Assert.Equal(0.3, pet.CurrentMoveSpeed);
}
// A pet chasing a combatant keeps the move table.
[Fact]
public void CombatChasingPet_KeepsMoveTable()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
var target = new PetTestStub();
target.MoveToWorld(new Point3D(1003, 1000, 0), Map.Felucca);
_created.Add(target);
pet.SetMoveSpeed(0.3, 0.9);
pet.ControlOrder = OrderType.Guard;
pet.Combatant = target;
pet.SetCurrentSpeedToActive();
Assert.Equal(0.3, pet.CurrentMoveSpeed);
}
// Herding overrides order pacing.
[Fact]
public void HerdedObeyingPet_KeepsHerdingPace()
{
var (_, pet) = Spawn(new Point3D(1000, 1000, 0), new Point3D(1001, 1000, 0));
pet.SetMoveSpeed(0.45, 0.9);
pet.SetCurrentSpeedToPassive();
pet.TargetLocation = new Point2D(1010, 1010);
Assert.Equal(0.3, pet.CurrentMoveSpeed); // fixed herding pace
}
private sealed class ThinkProbe : PetTestStub
{
public int Thinks;
public override void OnThink()
{
Thinks++;
base.OnThink();
}
}
private (PlayerMobile master, ThinkProbe pet) SpawnProbe()
{
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
master.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
_created.Add(master);
var pet = new ThinkProbe();
pet.MoveToWorld(new Point3D(1001, 1000, 0), Map.Felucca);
pet.SetControlMaster(master);
_created.Add(pet);
return (master, pet);
}
// Advances time in 8ms lockstep so the wheel and Core.TickCount stay in sync.
private static void RunFor(long ms)
{
var deadline = Core._tickCount + ms;
while (Core._tickCount < deadline)
{
Core._tickCount += 8;
Timer.Slice(Core._tickCount);
}
}
private static bool RunUntil(Func<bool> condition, long maxMs)
{
var deadline = Core._tickCount + maxMs;
while (Core._tickCount < deadline)
{
if (condition())
{
return true;
}
Core._tickCount += 8;
Timer.Slice(Core._tickCount);
}
return condition();
}
// Runs past the spawn stagger; returns right after a think with the next 0.4s away.
private ThinkProbe SettledProbe(out PlayerMobile master)
{
Core._tickCount = 0;
Timer.Init(0);
var (m, pet) = SpawnProbe();
master = m;
pet.ForceIdle = true; // no wandering; pure cadence
pet.ControlOrder = OrderType.Stay;
var settled = RunUntil(() => pet.Thinks >= 2, 8000);
Assert.True(settled, "the AI must reach a steady think cadence");
return pet;
}
[Fact]
public void OrderChange_WakesStaleThinkTimer()
{
var pet = SettledProbe(out var master);
var thinksBefore = pet.Thinks;
RunFor(200); // mid-wait, next think ~200ms out
Assert.Equal(thinksBefore, pet.Thinks);
pet.ControlTarget = master;
pet.ControlOrder = OrderType.Follow;
RunFor(80);
Assert.True(pet.Thinks > thinksBefore, "a fresh order must wake the AI promptly");
}
[Fact]
public void SpeedUp_ReschedulesPendingWake()
{
var pet = SettledProbe(out _);
var thinksBefore = pet.Thinks;
RunFor(200); // mid-wait, next think ~200ms out
Assert.Equal(thinksBefore, pet.Thinks);
pet.CurrentSpeed = 0.1;
RunFor(120);
Assert.True(pet.Thinks > thinksBefore, "a speed-up must reschedule the pending wake");
}
}

View file

@ -1,162 +0,0 @@
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// The Running bit is derived from the step pace: a step shorter than the client's walk
// interpolation (400ms on foot, 200ms mounted/flying) is flagged as a run.
[Collection("Sequential Pathfinding Tests")]
public class RunFlagTests : System.IDisposable
{
private readonly List<Mobile> _created = new();
private PetTestStub Spawn(double activeMove)
{
var pet = new PetTestStub();
pet.MoveToWorld(new Point3D(1000, 1000, 0), Map.Felucca);
pet.AIObject.AITimer?.Stop();
pet.SetMoveSpeed(activeMove, activeMove * 3);
pet.SetCurrentSpeedToActive();
pet.LastMoveTime = Core.TickCount; // mid-cadence unless a test says otherwise
_created.Add(pet);
return pet;
}
public void Dispose()
{
foreach (var m in _created)
{
m?.Delete();
}
_created.Clear();
}
[Theory]
[InlineData(0.3, true)]
[InlineData(0.125, true)]
[InlineData(0.4, false)]
[InlineData(0.45, false)]
[InlineData(1.05, false)]
public void FootCreature_RunsOnlyWhenFasterThanWalk(double activeMove, bool expected)
{
var pet = Spawn(activeMove);
Assert.Equal(activeMove, pet.CurrentMoveSpeed);
Assert.Equal(expected, pet.AIObject.ShouldRun());
}
[Theory]
[InlineData(0.3, false)]
[InlineData(0.15, true)]
public void FlyingCreature_UsesMountThresholds(double activeMove, bool expected)
{
var pet = Spawn(activeMove);
pet.Flying = true;
Assert.Equal(expected, pet.AIObject.ShouldRun());
}
[Fact]
public void BadlyHurt_SlowsBelowWalk_DropsToWalk()
{
var pet = Spawn(0.35);
Assert.True(pet.AIObject.ShouldRun());
// The hurt inflation is on the observed step pace, so the flag follows it.
pet.SetHits(100);
pet.Hits = 5;
pet.SetStam(100);
pet.Stam = 5;
Assert.False(pet.AIObject.ShouldRun());
}
[Theory]
[InlineData(0.3, true)]
[InlineData(0.45, false)]
public void DoMove_StampsRunningBit(double activeMove, bool expected)
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var pet = Spawn(activeMove);
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
var ai = pet.AIObject;
ai.NextMove = 0;
var start = pet.Location;
Assert.True(ai.DoMove(Direction.West));
Assert.NotEqual(start, pet.Location);
Assert.Equal(expected, (pet.Direction & Direction.Running) != 0);
}
// An isolated step (after standing at least a walk interval) renders alone and darts
// if run-flagged, so it walks; continuing cadences and true sprinters keep the flag.
[Fact]
public void IsolatedStep_DropsToWalk()
{
var pet = Spawn(0.3);
pet.LastMoveTime = Core.TickCount - 1000;
Assert.False(pet.AIObject.ShouldRun());
}
[Fact]
public void IsolatedStep_SprinterStillRuns()
{
var pet = Spawn(0.125);
pet.LastMoveTime = Core.TickCount - 1000;
Assert.True(pet.AIObject.ShouldRun());
}
[Fact]
public void StallDoesNotBankCatchUpSteps()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var pet = Spawn(0.3);
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
var ai = pet.AIObject;
ai.NextMove = Core.TickCount - 1000;
Assert.True(ai.DoMove(Direction.West));
// A stall must restart the cadence at full pace: banked catch-up steps
// release as a burst the client renders as a sprint/teleport.
Assert.False(ai.CanMoveNow(out _));
Assert.True(ai.NextMove - Core.TickCount > 250);
}
[Fact]
public void LateStepDoesNotEarnAQuickerFollowUp()
{
var map = Map.Maps[1];
Assert.NotNull(map);
map.GetAverageZ(1500, 1600, out _, out var z, out _);
var pet = Spawn(0.3);
pet.MoveToWorld(new Point3D(1500, 1600, (sbyte)z), map);
pet.Warmode = true; // keep the active move clock through the step
var ai = pet.AIObject;
// The step lands 200ms past the budget — under one period, the reactive
// mirroring case (think grid vs budget deadline misalignment).
ai.NextMove = Core.TickCount - 200;
Assert.True(ai.DoMove(Direction.West));
// The debt must not be repaid: a sub-period catch-up step follows ~100ms
// behind and renders as a dart pair beside the player.
Assert.True(ai.NextMove - Core.TickCount > 250);
}
}

View file

@ -1,146 +0,0 @@
using System.Collections.Generic;
using Server.Mobiles;
using Xunit;
namespace Server.Tests;
/// <summary>
/// Pins the looting-rights rules that the inline damage entry list has to keep producing: the
/// returned stores are sorted by damage descending, the first (least recent) damager takes the
/// 1.25x bonus, the hitsMax band decides who clears the threshold, and a pet's damage is credited
/// to its damage master rather than to the pet.
/// </summary>
[Collection("Sequential UOContent Tests")]
public class LootingRightsTests
{
private class TestMobile : Mobile
{
}
private class PetMobile : Mobile
{
public Mobile Master { get; set; }
public override Mobile GetDamageMaster(Mobile damagee) => Master;
}
// GetLootingRights only ever credits mobiles flagged as players.
private static TestMobile NewPlayer() => new() { Player = true };
private static DamageStore FindStore(List<DamageStore> rights, Mobile m)
{
for (var i = 0; i < rights.Count; i++)
{
if (rights[i].m_Mobile == m)
{
return rights[i];
}
}
return null;
}
[Fact]
public void TwoPlayerDamagers_SortDescending_AndTheFirstDamagerTakesTheBonus()
{
var victim = new TestMobile();
var first = NewPlayer();
var second = NewPlayer();
try
{
victim.RegisterDamage(100, first);
victim.RegisterDamage(40, second); // second is the most recent, first is the "first damager"
// hitsMax < 200 puts the bar at topDamage / 2.
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
Assert.Equal(2, rights.Count);
// Sorted by damage descending.
Assert.True(rights[0].m_Damage >= rights[1].m_Damage);
Assert.Same(first, rights[0].m_Mobile);
Assert.Same(second, rights[1].m_Mobile);
// The first damager - the least recent entry - gets the 1.25x bonus; nobody else does.
Assert.Equal(125, rights[0].m_Damage);
Assert.Equal(40, rights[1].m_Damage);
// topDamage 125 / 2 = 62, so 40 is below the bar.
Assert.True(rights[0].m_HasRight);
Assert.False(rights[1].m_HasRight);
}
finally
{
victim.Delete();
first.Delete();
second.Delete();
}
}
[Fact]
public void HitsMaxBand_MovesTheRightsThreshold()
{
var victim = new TestMobile();
var first = NewPlayer();
var second = NewPlayer();
try
{
victim.RegisterDamage(100, first);
victim.RegisterDamage(40, second);
// hitsMax >= 200 drops the bar to topDamage / 4 = 31, which 40 clears.
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 200);
Assert.Equal(2, rights.Count);
Assert.True(rights[0].m_HasRight);
Assert.True(rights[1].m_HasRight);
Assert.Same(second, rights[1].m_Mobile);
}
finally
{
victim.Delete();
first.Delete();
second.Delete();
}
}
[Fact]
public void PetDamage_CreditsTheMaster_NotThePet()
{
var victim = new TestMobile();
var master = NewPlayer();
var pet = new PetMobile { Master = master };
var wild = new TestMobile(); // no damage master, and not a player
try
{
victim.RegisterDamage(50, pet);
victim.RegisterDamage(20, wild);
var rights = BaseCreature.GetLootingRights(victim.DamageEntries, 100);
// The master is credited through the entry's Responsible sub-entry, and is the only one.
Assert.Single(rights);
var masterStore = FindStore(rights, master);
Assert.NotNull(masterStore);
Assert.Equal(62, masterStore.m_Damage); // 50, then the first-damager 1.25x bonus
Assert.True(masterStore.m_HasRight);
// The pet's own damage was fully handed to the master, so it earns no store.
Assert.Null(FindStore(rights, pet));
// A non-player damager earns nothing even when its damage was never reassigned.
Assert.Null(FindStore(rights, wild));
}
finally
{
victim.Delete();
master.Delete();
pet.Delete();
wild.Delete();
}
}
}

View file

@ -20,8 +20,8 @@ using Xunit;
namespace Server.Tests.Network.AutoDenylists; namespace Server.Tests.Network.AutoDenylists;
// Static store, so every test resets it first and none may run alongside another. // Static store, so every test resets it first. Addresses come from TEST-NET-2 (198.51.100.0/24).
// Addresses come from TEST-NET-2 (198.51.100.0/24). // Sequential: the cap tests reach Sweep, which rents from STArrayPool, which is not thread-safe.
[Collection("Sequential UOContent Tests")] [Collection("Sequential UOContent Tests")]
public class AutoDenylistTests public class AutoDenylistTests
{ {
@ -62,11 +62,8 @@ public class AutoDenylistTests
Assert.Equal(0, AutoDenylist.Count); 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] [Fact]
public void Repeat_detection_does_not_extend_the_hold() public void Repeat_detection_extends_the_hold()
{ {
Reset(); Reset();
var ip = IPAddress.Parse("198.51.100.13"); var ip = IPAddress.Parse("198.51.100.13");
@ -74,72 +71,8 @@ public class AutoDenylistTests
AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now); AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now);
AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1); AutoDenylist.Hold(ip, BanReasons.SilentConnect, Now + DurationMs - 1);
Assert.Equal(1, AutoDenylist.Count); // no duplicate Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs + 1)); // would have lapsed without the second
Assert.True(AutoDenylist.IsDenied(ip, Now + DurationMs - 1)); Assert.Equal(1, AutoDenylist.Count); // and did not add a duplicate
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] [Fact]

View file

@ -28,15 +28,15 @@ public class BanExemptionsTests
private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10"); private static readonly IPAddress _listed = IPAddress.Parse("192.0.2.10");
private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11"); private static readonly IPAddress _unlisted = IPAddress.Parse("192.0.2.11");
private static void WithManualAllowlist(string contents) => private static void WithFileAllowlist(string contents) =>
ManualAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _)); FileAllowlist.LoadForTesting(BlocklistSnapshot.Build(Encoding.ASCII.GetBytes(contents), out _, out _));
private static void WithEmptyManualAllowlist() => ManualAllowlist.LoadForTesting(BlocklistSnapshot.Empty); private static void WithEmptyFileAllowlist() => FileAllowlist.LoadForTesting(BlocklistSnapshot.Empty);
[Fact] [Fact]
public void Manual_allowlist_exempts_behavioral_contributions() public void File_allowlist_exempts_behavioral_contributions()
{ {
WithManualAllowlist("192.0.2.10"); WithFileAllowlist("192.0.2.10");
// Subtracting from the blocklist does nothing for behavioural detections, which never consult it. // 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.ForeignProtocol, NeverCalled));
@ -46,10 +46,10 @@ public class BanExemptionsTests
} }
[Fact] [Fact]
public void Manual_allowlist_covers_cidr_entries() public void File_allowlist_covers_cidr_entries()
{ {
// Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here. // Carve-outs are CIDRs, so a shared-CGNAT player is only covered if ranges work here.
WithManualAllowlist("192.0.2.0/24"); WithFileAllowlist("192.0.2.0/24");
Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled));
Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled)); Assert.True(BanExemptions.IsExempt(IPAddress.Parse("192.0.2.254"), BanReasons.RateLimit, NeverCalled));
@ -57,9 +57,9 @@ public class BanExemptionsTests
} }
[Fact] [Fact]
public void Manual_bans_are_never_exempt_even_when_allowlisted() public void Manual_bans_are_never_exempt_even_when_file_allowlisted()
{ {
WithManualAllowlist("192.0.2.10"); WithFileAllowlist("192.0.2.10");
// An explicit decision outranks the operator's own carve-out, and must not cost a strike. // An explicit decision outranks the operator's own carve-out, and must not cost a strike.
Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled)); Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Manual, NeverCalled));
@ -68,16 +68,16 @@ public class BanExemptionsTests
[Fact] [Fact]
public void Unopted_reasons_are_never_exempt() public void Unopted_reasons_are_never_exempt()
{ {
WithManualAllowlist("192.0.2.10"); WithFileAllowlist("192.0.2.10");
Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled)); Assert.False(BanExemptions.IsExempt(_listed, BanReasons.Blocklist, NeverCalled));
Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled)); Assert.False(BanExemptions.IsExempt(_listed, "some-future-reason", NeverCalled));
} }
[Fact] [Fact]
public void Manual_allowlist_does_not_spend_the_earned_lists_strikes() public void File_allowlist_does_not_spend_the_earned_lists_strikes()
{ {
WithManualAllowlist("192.0.2.10"); WithFileAllowlist("192.0.2.10");
// Unconditional, so the revocable list must not be consulted -- that would burn a strike. // Unconditional, so the revocable list must not be consulted -- that would burn a strike.
Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled)); Assert.True(BanExemptions.IsExempt(_listed, BanReasons.RateLimit, NeverCalled));
@ -86,7 +86,7 @@ public class BanExemptionsTests
[Fact] [Fact]
public void Falls_through_to_the_login_allowlist_when_not_file_listed() public void Falls_through_to_the_login_allowlist_when_not_file_listed()
{ {
WithEmptyManualAllowlist(); WithEmptyFileAllowlist();
var consulted = 0; var consulted = 0;
@ -107,7 +107,7 @@ public class BanExemptionsTests
[Fact] [Fact]
public void Null_address_is_never_exempt() public void Null_address_is_never_exempt()
{ {
WithEmptyManualAllowlist(); WithEmptyFileAllowlist();
Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled)); Assert.False(BanExemptions.IsExempt(null, BanReasons.RateLimit, NeverCalled));
} }

View file

@ -30,7 +30,6 @@ public class BlocklistConfigurationTests
{ {
var original = new BlocklistSettings var original = new BlocklistSettings
{ {
Enabled = true,
File = "D:/shared/ip-blocklist.txt", File = "D:/shared/ip-blocklist.txt",
ReloadInterval = TimeSpan.FromMinutes(5), ReloadInterval = TimeSpan.FromMinutes(5),
ReportHits = false, ReportHits = false,
@ -40,7 +39,6 @@ public class BlocklistConfigurationTests
var json = JsonConfig.Serialize(original); var json = JsonConfig.Serialize(original);
Assert.Contains("\"enabled\"", json);
Assert.Contains("\"file\"", json); Assert.Contains("\"file\"", json);
Assert.Contains("\"reloadInterval\"", json); Assert.Contains("\"reloadInterval\"", json);
Assert.Contains("\"reportHits\"", json); Assert.Contains("\"reportHits\"", json);
@ -50,7 +48,6 @@ public class BlocklistConfigurationTests
var restored = JsonSerializer.Deserialize<BlocklistSettings>(json, JsonConfig.DefaultOptions); var restored = JsonSerializer.Deserialize<BlocklistSettings>(json, JsonConfig.DefaultOptions);
Assert.NotNull(restored); Assert.NotNull(restored);
Assert.Equal(original.Enabled, restored.Enabled);
Assert.Equal(original.File, restored.File); Assert.Equal(original.File, restored.File);
Assert.Equal(original.ReloadInterval, restored.ReloadInterval); Assert.Equal(original.ReloadInterval, restored.ReloadInterval);
Assert.Equal(original.ReportHits, restored.ReportHits); Assert.Equal(original.ReportHits, restored.ReportHits);
@ -58,13 +55,6 @@ public class BlocklistConfigurationTests
Assert.Equal(original.PromoteSuppression, restored.PromoteSuppression); 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 // The generator (tools/Export-IpBlocklist.ps1) writes to this path by default; if one side moves
// without the other, a shard silently enforces nothing. // without the other, a shard silently enforces nothing.
[Fact] [Fact]

View file

@ -1,66 +0,0 @@
/*************************************************************************
* 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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
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<ManualAllowlistSettings>(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);
}
}

View file

@ -1,382 +0,0 @@
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));
}
}

View file

@ -4,9 +4,9 @@
<Configurations>Debug;Release;Analyze</Configurations> <Configurations>Debug;Release;Analyze</Configurations>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@ -15,6 +15,7 @@
<ProjectReference Include="..\UOContent\UOContent.csproj" /> <ProjectReference Include="..\UOContent\UOContent.csproj" />
<ProjectReference Include="..\Server.Tests\Server.Tests.csproj" /> <ProjectReference Include="..\Server.Tests\Server.Tests.csproj" />
<DataFiles Include="$(SolutionDir)\Distribution\Data\**" /> <DataFiles Include="$(SolutionDir)\Distribution\Data\**" />
<PackageReference Update="Serilog" Version="4.4.0" />
</ItemGroup> </ItemGroup>
<Target Name="CopyData" AfterTargets="AfterBuild"> <Target Name="CopyData" AfterTargets="AfterBuild">
<Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" /> <Copy SourceFiles="@(DataFiles)" DestinationFolder="$(OutDir)\Data\%(RecursiveDir)" />

View file

@ -378,54 +378,28 @@ public partial class Account : IAccount, IComparable<Account>
public void SetPassword(string plainPassword) 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; PasswordAlgorithm = AccountSecurity.CurrentAlgorithm;
Password = AccountSecurity.CurrentPasswordProtection.EncryptPassword(
AccountSecurity.DerivePhrase(PasswordAlgorithm, _username, plainPassword)
);
}
/// <summary>The phrase that verifies against the currently stored hash.</summary>
internal string GetVerifyPhrase(string plainPassword) =>
AccountSecurity.DerivePhrase(_passwordAlgorithm, _username, plainPassword);
/// <summary>The phrase a rehash to the configured algorithm would be derived from.</summary>
internal string GetRehashPhrase(string plainPassword) =>
AccountSecurity.DerivePhrase(AccountSecurity.CurrentAlgorithm, _username, plainPassword);
/// <summary>
/// Whether a successful login should rewrite the stored hash, because the algorithm changed or
/// its cost parameters moved.
/// </summary>
internal bool NeedsPasswordUpgrade() =>
_passwordAlgorithm != AccountSecurity.CurrentAlgorithm ||
AccountSecurity.CurrentPasswordProtection.NeedsRehash(Password);
/// <summary>
/// Applies a hash derived off the game loop. Distinct from the private <c>UpgradePassword</c>
/// 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.
/// </summary>
internal void ApplyPasswordWrite(string newEncrypted, PasswordProtectionAlgorithm algorithm)
{
PasswordAlgorithm = algorithm;
Password = newEncrypted;
} }
public bool CheckPassword(string plainPassword) public bool CheckPassword(string plainPassword)
{ {
var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm) var phrase = _passwordAlgorithm is PasswordProtectionAlgorithm.SHA1 or PasswordProtectionAlgorithm.SHA2
.ValidatePassword(Password, GetVerifyPhrase(plainPassword)); ? $"{_username}{plainPassword}"
: plainPassword;
var ok = AccountSecurity.GetPasswordProtection(_passwordAlgorithm).ValidatePassword(Password, phrase);
if (!ok) if (!ok)
{ {
return false; return false;
} }
// Upgrade the password protection in case we change the algorithm // Upgrade the password protection in case we change the algorithm
if (NeedsPasswordUpgrade()) if (_passwordAlgorithm != AccountSecurity.CurrentAlgorithm)
{ {
SetPassword(plainPassword); SetPassword(plainPassword);
} }

View file

@ -5,7 +5,6 @@ using System.Net;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using ModernUO.CodeGeneratedEvents; using ModernUO.CodeGeneratedEvents;
using Server.Accounting; using Server.Accounting;
using Server.Accounting.Security;
using Server.Engines.CharacterCreation; using Server.Engines.CharacterCreation;
using Server.Engines.Help; using Server.Engines.Help;
using Server.Logging; using Server.Logging;
@ -70,9 +69,6 @@ public static class AccountHandler
public static void Initialize() public static void Initialize()
{ {
EventSink.AccountLogin += EventSink_AccountLogin; EventSink.AccountLogin += EventSink_AccountLogin;
EventSink.Shutdown += PasswordWorker.Stop;
EventSink.ServerCrashed += PasswordWorker.OnCrashed;
} }
[Usage("Password <newPassword> <repeatPassword>")] [Usage("Password <newPassword> <repeatPassword>")]
@ -143,12 +139,8 @@ public static class AccountHandler
if (accessList[0].MatchClassC(ipAddress)) if (accessList[0].MatchClassC(ipAddress))
{ {
// Confirmed from the callback: off-loop the write has not landed yet here. acct.SetPassword(pass);
PasswordWorker.SetPassword( from.SendMessage("The password to your account has changed.");
acct,
pass,
_ => from.SendMessage("The password to your account has changed.")
);
} }
else else
{ {
@ -315,129 +307,25 @@ public static class AccountHandler
logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un); logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un);
e.RejectReason = LockdownLevel > AccessLevel.Player ? ALRReason.BadComm : ALRReason.BadPass; e.RejectReason = LockdownLevel > AccessLevel.Player ? ALRReason.BadComm : ALRReason.BadPass;
} }
else else if (!acct.CheckPassword(pw))
{ {
HandlePasswordCheck(e, acct, pw); logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un);
}
}
/// <summary>
/// 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.
/// </summary>
private static void HandlePasswordCheck(AccountLoginEventArgs e, Account acct, string pw)
{
switch (DispatchPasswordCheck(e, acct, pw))
{
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; e.RejectReason = ALRReason.BadPass;
return;
} }
else if (acct.Banned)
ApplyVerifiedLogin(e, acct);
}
/// <summary>Everything after the password is known good, shared so an off-loop verdict lands
/// in the same state as an inline one.</summary>
private static void ApplyVerifiedLogin(AccountLoginEventArgs e, Account acct)
{
if (acct.Banned)
{ {
logger.Information("Login: {NetState} Banned account '{Username}'", e.State, acct.Username); logger.Information("Login: {NetState} Banned account '{Username}'", e.State, un);
e.RejectReason = ALRReason.Blocked; 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
{
/// <summary>Verify on the loop.</summary>
Inline,
/// <summary>Handed to the worker; no verdict yet.</summary>
Deferred,
/// <summary>The queue is full.</summary>
Saturated
}
/// <summary>
/// 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:
/// <c>AccountSecurity.Configure</c> refuses anything below SHA2 as the configured algorithm, so
/// MD5 and SHA1 only appear as a stored hash awaiting migration. That makes
/// <c>NeedsPasswordUpgrade</c> true, and the upgrade hash dominates the job.
/// </summary>
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;
}
/// <summary>Resumes a login whose password check ran on the verification thread.</summary>
internal static void CompleteDeferredAccountLogin(NetState state, Account acct, bool verified)
{
var e = new AccountLoginEventArgs(state, acct.Username, null);
if (verified)
{
ApplyVerifiedLogin(e, acct);
} }
else else
{ {
logger.Information("Login: {NetState} Invalid password for '{Username}'", state, acct.Username); logger.Information("Login: {NetState} Valid credentials for '{Username}'", e.State, un);
e.RejectReason = ALRReason.BadPass; e.State.Account = acct;
} e.Accepted = true;
IncomingAccountPackets.CompleteAccountLogin(state, e.Accepted, e.RejectReason); acct.LogAccess(e.State);
LoginAllowlist.RecordLogin(e.State?.Address);
}
} }
[OnEvent(nameof(GameServer.GameServerLoginEvent))] [OnEvent(nameof(GameServer.GameServerLoginEvent))]
@ -455,9 +343,7 @@ public static class AccountHandler
logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un); logger.Information("Login: {NetState} Access denied for '{Username}'", e.State, un);
e.Accepted = false; e.Accepted = false;
} }
// The auth id was only issued after the account login packet verified this password, so else if (!acct.CheckPassword(pw))
// 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); logger.Information("Login: {NetState} Invalid password for '{Username}'", e.State, un);
e.Accepted = false; e.Accepted = false;

View file

@ -4,12 +4,5 @@ namespace Server.Accounting
{ {
string EncryptPassword(string plainPassword); string EncryptPassword(string plainPassword);
bool ValidatePassword(string encryptedPassword, string plainPassword); bool ValidatePassword(string encryptedPassword, string plainPassword);
/// <summary>
/// True when <paramref name="encryptedPassword"/> 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.
/// </summary>
bool NeedsRehash(string encryptedPassword) => false;
} }
} }

View file

@ -51,17 +51,6 @@ public static class AccountSecurity
} }
} }
/// <summary>
/// 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.
/// </summary>
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) public static IPasswordProtection GetPasswordProtection(PasswordProtectionAlgorithm algorithm)
{ {
var passwordProtection = algorithm switch var passwordProtection = algorithm switch

View file

@ -21,37 +21,11 @@ public class Argon2PasswordProtection : IPasswordProtection
{ {
public static IPasswordProtection Instance = new Argon2PasswordProtection(); public static IPasswordProtection Instance = new Argon2PasswordProtection();
// 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 private readonly Argon2PasswordHasher m_PasswordHasher = new(rng: RandomNumberGenerator.Create());
// 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) => public string EncryptPassword(string plainPassword) =>
_passwordHasher.Hash(plainPassword); m_PasswordHasher.Hash(plainPassword);
public bool ValidatePassword(string encryptedPassword, string plainPassword) => public bool ValidatePassword(string encryptedPassword, string plainPassword) =>
_passwordHasher.Verify(encryptedPassword, plainPassword); m_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;
}
} }

View file

@ -19,47 +19,19 @@ using Server.Text;
namespace Server.Accounting.Security; namespace Server.Accounting.Security;
/// <summary>
/// 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 <see cref="HashAlgorithm"/>.
/// A <see cref="HashAlgorithm"/> 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.
/// </summary>
public class HashAlgorithmPasswordProtection : IPasswordProtection public class HashAlgorithmPasswordProtection : IPasswordProtection
{ {
private enum Kind public static IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(MD5.Create());
{ public static IPasswordProtection SHA1Instance = new HashAlgorithmPasswordProtection(SHA1.Create());
MD5, public static IPasswordProtection SHA2Instance = new HashAlgorithmPasswordProtection(SHA512.Create());
SHA1, private readonly HashAlgorithm _hashAlgorithm;
SHA512
}
public static readonly IPasswordProtection MD5Instance = new HashAlgorithmPasswordProtection(Kind.MD5); public HashAlgorithmPasswordProtection(HashAlgorithm hashAlgorithm) => _hashAlgorithm = hashAlgorithm;
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) public string EncryptPassword(string plainPassword)
{ {
var bytes = plainPassword.AsSpan(0, Math.Min(256, plainPassword.Length)).GetBytesAscii(); var bytes = plainPassword.AsSpan(0, Math.Min(256, plainPassword.Length)).GetBytesAscii();
return _hashAlgorithm.ComputeHash(bytes).ToHexString();
Span<byte> 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) => public bool ValidatePassword(string encryptedPassword, string plainPassword) =>

View file

@ -22,24 +22,23 @@ namespace Server.Accounting.Security;
public class PBKDF2PasswordProtection : IPasswordProtection public class PBKDF2PasswordProtection : IPasswordProtection
{ {
private const ushort MinIterations = 1024; private const ushort m_MinIterations = 1024;
private const ushort MaxIterations = 1536; private const ushort m_MaxIterations = 1536;
private const int SaltSize = 8; private const int m_SaltSize = 8;
private const int HashSize = 32; private const int m_HashSize = 32;
private const int OutputSize = 2 + SaltSize + HashSize; private const int m_OutputSize = 2 + m_SaltSize + m_HashSize;
public static readonly IPasswordProtection Instance = new PBKDF2PasswordProtection(); public static readonly IPasswordProtection Instance = new PBKDF2PasswordProtection();
public string EncryptPassword(string plainPassword) public string EncryptPassword(string plainPassword)
{ {
Span<byte> output = stackalloc byte[OutputSize]; Span<byte> output = stackalloc byte[m_OutputSize];
var iterations = Utility.RandomMinMax(m_MinIterations, m_MaxIterations);
var iterations = RandomNumberGenerator.GetInt32(MinIterations, MaxIterations + 1);
BinaryPrimitives.WriteUInt16LittleEndian(output[..2], (ushort)iterations); BinaryPrimitives.WriteUInt16LittleEndian(output[..2], (ushort)iterations);
var salt = output.Slice(2, SaltSize); var salt = output.Slice(2, m_SaltSize);
RandomNumberGenerator.Fill(salt); RandomNumberGenerator.Fill(salt);
var hash = output.Slice(2 + SaltSize, HashSize); var hash = output.Slice(2 + m_SaltSize, m_HashSize);
Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256); Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256);
return output.ToHexString(); return output.ToHexString();
@ -47,15 +46,15 @@ public class PBKDF2PasswordProtection : IPasswordProtection
public bool ValidatePassword(string encryptedPassword, string plainPassword) public bool ValidatePassword(string encryptedPassword, string plainPassword)
{ {
Span<byte> encryptedBytes = stackalloc byte[OutputSize]; Span<byte> encryptedBytes = stackalloc byte[m_OutputSize];
encryptedPassword.GetBytes(encryptedBytes); encryptedPassword.GetBytes(encryptedBytes);
var iterations = BinaryPrimitives.ReadUInt16LittleEndian(encryptedBytes[..2]); var iterations = BinaryPrimitives.ReadUInt16LittleEndian(encryptedBytes[..2]);
var salt = encryptedBytes.Slice(2, SaltSize); var salt = encryptedBytes.Slice(2, m_SaltSize);
Span<byte> hash = stackalloc byte[HashSize]; Span<byte> hash = stackalloc byte[m_HashSize];
Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256); Rfc2898DeriveBytes.Pbkdf2(plainPassword, salt, hash, iterations, HashAlgorithmName.SHA256);
return hash.SequenceEqual(encryptedBytes[(SaltSize + 2)..]); return hash.SequenceEqual(encryptedBytes[(m_SaltSize + 2)..]);
} }
} }

View file

@ -1,293 +0,0 @@
/*************************************************************************
* 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 <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Concurrent;
using System.Threading;
using Server.Logging;
using Server.Network;
namespace Server.Accounting.Security;
/// <summary>
/// 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.
/// </summary>
internal sealed class PasswordJob
{
public Account Account;
/// <summary>Ties the job to a connection. Null when the work is not gated on one, such as a
/// password change by an admin.</summary>
public NetState State;
/// <summary>Hash to verify against, with <see cref="VerifyPhrase"/>.</summary>
public string StoredHash;
/// <summary>Algorithm <see cref="StoredHash"/> was written with. Both algorithms are resolved on
/// the loop; <c>AccountSecurity.CurrentAlgorithm</c> is mutable state the worker must not read.</summary>
public PasswordProtectionAlgorithm StoredAlgorithm;
/// <summary>Phrase to verify, or null to skip verification.</summary>
public string VerifyPhrase;
/// <summary>Phrase to hash, or null when nothing needs writing.</summary>
public string HashPhrase;
public PasswordProtectionAlgorithm TargetAlgorithm;
/// <summary>Runs on the game loop with the result. Free to touch game state.</summary>
public Action<PasswordJob, PasswordOutcome> OnComplete;
}
internal readonly struct PasswordOutcome
{
/// <summary>True when no verification was asked for, or it succeeded.</summary>
public readonly bool Verified;
/// <summary>The derived hash, or null when nothing was hashed or verification failed.</summary>
public readonly string Hash;
public PasswordOutcome(bool verified, string hash)
{
Verified = verified;
Hash = hash;
}
}
/// <summary>
/// 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.
/// </summary>
internal sealed class PasswordWorker
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(PasswordWorker));
/// <summary>
/// Backstop, not a flood defense. <c>SentFirstPacket</c> holds a connection to one pending
/// verify and the engine caps connections at 4096 (<c>NetState.Network.cs</c>), 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.
/// </summary>
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<PasswordJob> _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();
/// <summary>Queues a job. False when full, and the caller must then reject without verifying.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Sets a password, off the loop where available and inline otherwise, invoking
/// <paramref name="onDone"/> on the loop either way.
///
/// Confirm from <paramref name="onDone"/>, not the call site: off-loop the write has not
/// happened when this returns.
/// </summary>
internal static void SetPassword(Account account, string plainPassword, Action<bool> 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);
}
}
/// <summary>Runs a job on the calling thread. The seam the tests drive.</summary>
internal static PasswordOutcome ComputeInline(PasswordJob job) => Compute(job);
/// <summary>
/// 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.
/// </summary>
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));
}
}

View file

@ -1,117 +0,0 @@
#if EVENT_LOOP_PROFILING
using System;
using System.Globalization;
using System.IO;
using Server.Logging;
namespace Server.Commands;
/// <summary>
/// Reports the event-loop time decomposition recorded by <see cref="EventLoopProfiler"/>.
/// Only compiled when the server is built with -p:EventLoopProfiling=true.
/// See dev-docs/debugging-event-loop.md for how to read the output.
/// </summary>
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<double> phases = stackalloc double[EventLoopProfiler.PhaseCount];
Span<double> 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

View file

@ -103,8 +103,9 @@ namespace Server.Commands
{ {
var list = pm.VisibilityList; var list = pm.VisibilityList;
if (list.Remove(targ)) if (list.Contains(targ))
{ {
list.Remove(targ);
pm.SendMessage($"{targ.Name} has been removed from your visibility list."); pm.SendMessage($"{targ.Name} has been removed from your visibility list.");
} }
else else

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Buffers; using System.Buffers;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Numerics; using System.Numerics;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;

View file

@ -6,27 +6,27 @@ namespace Server.Engines.BulkOrders;
public partial class BOBFilter public partial class BOBFilter
{ {
[SerializableField(0)] [SerializableField(0)]
[SaveFlag(nameof(ShouldSerializeType))]
private int _type; private int _type;
[SerializableFieldSaveFlag(0)]
private bool ShouldSerializeType() => _type != 0; private bool ShouldSerializeType() => _type != 0;
[SerializableField(1)] [SerializableField(1)]
[SaveFlag(nameof(ShouldSerializeQuality))]
private int _quality; private int _quality;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeQuality() => _quality != 0; private bool ShouldSerializeQuality() => _quality != 0;
[SerializableField(2)] [SerializableField(2)]
[SaveFlag(nameof(ShouldSerializeMaterial))]
private int _material; private int _material;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeMaterial() => _material != 0; private bool ShouldSerializeMaterial() => _material != 0;
[SerializableField(3)] [SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeQuantity))]
private int _quantity; private int _quantity;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeQuantity() => _quantity != 0; private bool ShouldSerializeQuantity() => _quantity != 0;
private void Deserialize(IGenericReader reader, int version) private void Deserialize(IGenericReader reader, int version)

View file

@ -33,13 +33,17 @@ public partial class ChampionSkullBrazier : AddonComponent
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private ChampionSkullPlatform _platform; private ChampionSkullPlatform _platform;
[SerializableField(2, fieldChanged: nameof(OnSkullChanged))] [SerializableProperty(2)]
[SerializedCommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
private Item _skull; public Item Skull
private void OnSkullChanged(Item oldValue, Item newValue)
{ {
_platform?.Validate(); get => _skull;
set
{
_skull = value;
this.MarkDirty();
_platform?.Validate();
}
} }
public override int LabelNumber => 1049489 + (int)_type; public override int LabelNumber => 1049489 + (int)_type;

View file

@ -18,7 +18,6 @@ using System.Net;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using ModernUO.Serialization; using ModernUO.Serialization;
using Server.Collections;
using Server.Engines.Virtues; using Server.Engines.Virtues;
using Server.Gumps; using Server.Gumps;
using Server.Items; using Server.Items;
@ -28,44 +27,16 @@ using Server.Logging;
namespace Server.Engines.CannedEvil; namespace Server.Engines.CannedEvil;
[SerializationGenerator(11, false)] [SerializationGenerator(10, false)]
public partial class ChampionSpawn : Item 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)); private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn));
[SerializableField(1)] [SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private bool _activatedByProximity; private bool _activatedByProximity;
[AnchoredDateTime] [DeltaDateTime]
[SerializableField(2)] [SerializableField(2)]
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _nextProximityTime; private DateTime _nextProximityTime;
@ -125,7 +96,7 @@ public partial class ChampionSpawn : Item
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private TimeSpan _expireDelay; private TimeSpan _expireDelay;
[AnchoredDateTime] [DeltaDateTime]
[SerializableField(20)] [SerializableField(20)]
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _expireTime; private DateTime _expireTime;
@ -138,7 +109,7 @@ public partial class ChampionSpawn : Item
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private TimeSpan _restartDelay; private TimeSpan _restartDelay;
[AnchoredDateTime] [DeltaDateTime]
[SerializableField(23, setter: "private")] [SerializableField(23, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
private DateTime _restartTime; private DateTime _restartTime;
@ -232,38 +203,47 @@ public partial class ChampionSpawn : Item
} }
} }
[SerializableField(3, allowFieldChange: nameof(AllowMaxLevelChange))] [SerializableProperty(3)]
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] [CommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private int _maxLevel; public int MaxLevel
private bool AllowMaxLevelChange(ref int value)
{ {
value = Math.Clamp(value, 0, 18); get => _maxLevel;
return true; set => _maxLevel = Math.Clamp(value, 0, 18);
} }
[SerializableField(9, fieldChanged: nameof(OnSpawnAreaChanged))] [SerializableProperty(9)]
[SerializedCommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties] public Rectangle2D SpawnArea
private Rectangle2D _spawnArea;
private void OnSpawnAreaChanged(Rectangle2D oldValue, Rectangle2D newValue)
{ {
UpdateRegion(); get => _spawnArea;
} set
[SerializableField(11, fieldChanged: nameof(OnKillsChanged))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _kills;
private void OnKillsChanged(int oldValue, int newValue)
{
var n = _kills / (double)MaxKills;
var p = (int)(n * 100);
if (p < 90)
{ {
SetWhiteSkullCount(p / 20); _spawnArea = value;
this.MarkDirty();
InvalidateProperties();
UpdateRegion();
}
}
[SerializableProperty(11)]
[CommandProperty(AccessLevel.GameMaster)]
public int Kills
{
get => _kills;
set
{
_kills = value;
this.MarkDirty();
var n = _kills / (double)MaxKills;
var p = (int)(n * 100);
if (p < 90)
{
SetWhiteSkullCount(p / 20);
}
InvalidateProperties();
} }
} }
@ -1182,6 +1162,11 @@ public partial class ChampionSpawn : Item
foreach (var de in m.DamageEntries) foreach (var de in m.DamageEntries)
{ {
if (de.HasExpired)
{
continue;
}
var damager = de.Damager; var damager = de.Damager;
var master = damager.GetDamageMaster(m); var master = damager.GetDamageMaster(m);

View file

@ -51,9 +51,9 @@ public partial class ChampionTitleContext
} }
[SerializableField(1)] [SerializableField(1)]
[SaveFlag(nameof(ShouldSerializeAbyss))]
private ChampionTitle _abyss; private ChampionTitle _abyss;
[SerializableFieldSaveFlag(1)]
private bool ShouldSerializeAbyss() => _abyss != null; private bool ShouldSerializeAbyss() => _abyss != null;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
@ -71,9 +71,9 @@ public partial class ChampionTitleContext
} }
[SerializableField(2)] [SerializableField(2)]
[SaveFlag(nameof(ShouldSerializeArachnid))]
private ChampionTitle _arachnid; private ChampionTitle _arachnid;
[SerializableFieldSaveFlag(2)]
private bool ShouldSerializeArachnid() => _arachnid != null; private bool ShouldSerializeArachnid() => _arachnid != null;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
@ -91,9 +91,9 @@ public partial class ChampionTitleContext
} }
[SerializableField(3)] [SerializableField(3)]
[SaveFlag(nameof(ShouldSerializeColdBlood))]
private ChampionTitle _coldBlood; private ChampionTitle _coldBlood;
[SerializableFieldSaveFlag(3)]
private bool ShouldSerializeColdBlood() => _coldBlood != null; private bool ShouldSerializeColdBlood() => _coldBlood != null;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
@ -111,9 +111,9 @@ public partial class ChampionTitleContext
} }
[SerializableField(4)] [SerializableField(4)]
[SaveFlag(nameof(ShouldSerializeForestLord))]
private ChampionTitle _forestLord; private ChampionTitle _forestLord;
[SerializableFieldSaveFlag(4)]
private bool ShouldSerializeForestLord() => _forestLord != null; private bool ShouldSerializeForestLord() => _forestLord != null;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
@ -131,9 +131,9 @@ public partial class ChampionTitleContext
} }
[SerializableField(5)] [SerializableField(5)]
[SaveFlag(nameof(ShouldSerializeVerminHorde))]
private ChampionTitle _verminHorde; private ChampionTitle _verminHorde;
[SerializableFieldSaveFlag(5)]
private bool ShouldSerializeVerminHorde() => _verminHorde != null; private bool ShouldSerializeVerminHorde() => _verminHorde != null;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
@ -151,9 +151,9 @@ public partial class ChampionTitleContext
} }
[SerializableField(6)] [SerializableField(6)]
[SaveFlag(nameof(ShouldSerializeUnholyTerror))]
private ChampionTitle _unholyTerror; private ChampionTitle _unholyTerror;
[SerializableFieldSaveFlag(6)]
private bool ShouldSerializeUnholyTerror() => _unholyTerror != null; private bool ShouldSerializeUnholyTerror() => _unholyTerror != null;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
@ -171,9 +171,9 @@ public partial class ChampionTitleContext
} }
[SerializableField(7)] [SerializableField(7)]
[SaveFlag(nameof(ShouldSerializeSleepingDragon))]
private ChampionTitle _sleepingDragon; private ChampionTitle _sleepingDragon;
[SerializableFieldSaveFlag(7)]
private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null; private bool ShouldSerializeSleepingDragon() => _sleepingDragon != null;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
@ -191,9 +191,9 @@ public partial class ChampionTitleContext
} }
[SerializableField(8)] [SerializableField(8)]
[SaveFlag(nameof(ShouldSerializeCorrupt))]
private ChampionTitle _corrupt; private ChampionTitle _corrupt;
[SerializableFieldSaveFlag(8)]
private bool ShouldSerializeCorrupt() => _corrupt != null; private bool ShouldSerializeCorrupt() => _corrupt != null;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
@ -211,9 +211,9 @@ public partial class ChampionTitleContext
} }
[SerializableField(9)] [SerializableField(9)]
[SaveFlag(nameof(ShouldSerializeGlade))]
private ChampionTitle _glade; private ChampionTitle _glade;
[SerializableFieldSaveFlag(9)]
private bool ShouldSerializeGlade() => _glade != null; private bool ShouldSerializeGlade() => _glade != null;
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]

View file

@ -167,13 +167,20 @@ public class ChampionTitleSystem : GenericPersistence
return; return;
} }
using var queue = PooledRefQueue<Mobile>.Create();
foreach (var context in _championTitleContexts.Values) foreach (var context in _championTitleContexts.Values)
{ {
if (!context.CheckAtrophy()) if (!context.CheckAtrophy())
{ {
_championTitleContexts.Remove(context.Player); queue.Enqueue(context.Player);
} }
} }
while (queue.Count > 0)
{
_championTitleContexts.Remove((PlayerMobile)queue.Dequeue());
}
} }
} }
} }

View file

@ -3,21 +3,15 @@ using ModernUO.Serialization;
namespace Server.Items; namespace Server.Items;
[SerializationGenerator(2, false)] [SerializationGenerator(1, false)]
public partial class StarRoomGate : Moongate public partial class StarRoomGate : Moongate
{ {
private void MigrateFrom(V1Content content)
{
_decays = content.Decays;
_decayTime = content.DecayTime;
}
private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0); private static TimeSpan GateDuration = TimeSpan.FromMinutes(2.0);
[SerializableField(0)] [SerializableField(0)]
private bool _decays; private bool _decays;
[AnchoredDateTime] [DeltaDateTime]
[SerializableField(1)] [SerializableField(1)]
private DateTime _decayTime; private DateTime _decayTime;

View file

@ -146,8 +146,15 @@ namespace Server.Engines.Chat
m_Users.Remove(user); m_Users.Remove(user);
user.CurrentChannel = null; user.CurrentChannel = null;
m_Moderators.Remove(user); if (m_Moderators.Contains(user))
m_Voices.Remove(user); {
m_Moderators.Remove(user);
}
if (m_Voices.Contains(user))
{
m_Voices.Remove(user);
}
SendCommand(ChatCommand.RemoveUserFromChannel, user, user.Username); SendCommand(ChatCommand.RemoveUserFromChannel, user, user.Username);
ChatSystem.SendCommandTo(user.Mobile, ChatCommand.LeaveChannel); ChatSystem.SendCommandTo(user.Mobile, ChatCommand.LeaveChannel);
@ -176,7 +183,10 @@ namespace Server.Engines.Chat
public void RemoveBan(ChatUser user) public void RemoveBan(ChatUser user)
{ {
m_Banned.Remove(user); if (m_Banned.Contains(user))
{
m_Banned.Remove(user);
}
} }
public void Kick(ChatUser user, ChatUser moderator = null) public void Kick(ChatUser user, ChatUser moderator = null)

View file

@ -716,7 +716,10 @@ public partial class BRBomb : Item
m.Target = new BombTarget(this, m); m.Target = new BombTarget(this, m);
m_Helpers.Remove(m); if (m_Helpers.Contains(m))
{
m_Helpers.Remove(m);
}
if (m_Helpers.Count > 0) if (m_Helpers.Count > 0)
{ {
@ -830,9 +833,10 @@ public partial class BRBomb : Item
[SerializationGenerator(0, false)] [SerializationGenerator(0, false)]
public partial class BRGoal : BaseAddon public partial class BRGoal : BaseAddon
{ {
[SerializableField(0, fieldChanged: nameof(OnNorthChanged))] [SerializableField(0)]
private bool _north; private bool _north;
[SerializableFieldChanged(0)]
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnNorthChanged(bool oldValue, bool newValue) => Remake(); private void OnNorthChanged(bool oldValue, bool newValue) => Remake();

View file

@ -252,9 +252,10 @@ public partial class HillOfTheKing : Item
public partial class KHBoard : Item public partial class KHBoard : Item
{ {
[SerializedCommandProperty(AccessLevel.GameMaster)] [SerializedCommandProperty(AccessLevel.GameMaster)]
[SerializableField(0, fieldChanged: nameof(OnControllerChanged))] [SerializableField(0)]
private KHController _controller; private KHController _controller;
[SerializableFieldChanged(0)]
private void OnControllerChanged(KHController oldValue, KHController newValue) private void OnControllerChanged(KHController oldValue, KHController newValue)
{ {
oldValue?.RemoveBoard(this); oldValue?.RemoveBoard(this);

View file

@ -56,11 +56,12 @@ namespace Server.Engines.ConPVP
public void RemoveFlavor(Ruleset flavor) public void RemoveFlavor(Ruleset flavor)
{ {
if (!Flavors.Remove(flavor)) if (!Flavors.Contains(flavor))
{ {
return; return;
} }
Flavors.Remove(flavor);
Options.And(flavor.Options.Not()); Options.And(flavor.Options.Not());
flavor.Options.Not(); flavor.Options.Not();
} }

View file

@ -64,13 +64,17 @@ public partial class Trophy : Item
UpdateStyle(); UpdateStyle();
} }
[SerializableField(1, fieldChanged: nameof(OnRankChanged))] [SerializableProperty(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
private TrophyRank _rank; public TrophyRank Rank
private void OnRankChanged(TrophyRank oldValue, TrophyRank newValue)
{ {
UpdateStyle(); get => _rank;
set
{
_rank = value;
UpdateStyle();
this.MarkDirty();
}
} }
private void Deserialize(IGenericReader reader, int version) private void Deserialize(IGenericReader reader, int version)

View file

@ -1451,9 +1451,6 @@ namespace Server.Engines.Craft
if (item != null) if (item != null)
{ {
// Stamped here, not in OnCraft: most craftables do not implement ICraftable.
item.PlayerConstructed = true;
if (item is ICraftable craftable) if (item is ICraftable craftable)
{ {
endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue);
@ -1745,9 +1742,6 @@ namespace Server.Engines.Craft
if (item != null) if (item != null)
{ {
// Stamped here, not in OnCraft: most craftables do not implement ICraftable.
item.PlayerConstructed = true;
if (item is ICraftable craftable) if (item is ICraftable craftable)
{ {
endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue); endquality = craftable.OnCraft(quality, makersMark, from, craftSystem, typeRes, tool, this, resHue);

View file

@ -5,20 +5,9 @@ using Server.Mobiles;
namespace Server.Ethics; namespace Server.Ethics;
[PropertyObject] [PropertyObject]
[SerializationGenerator(2)] [SerializationGenerator(1)]
public partial class Player : EthicsEntity 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")] [SerializableField(0, setter: "private")]
private Mobile _mobile; private Mobile _mobile;
@ -38,7 +27,7 @@ public partial class Player : EthicsEntity
[SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)] [SerializedCommandProperty(AccessLevel.GameMaster, AccessLevel.Administrator)]
private Mobile _familiar; private Mobile _familiar;
[AnchoredDateTime] [DeltaDateTime]
[SerializableField(5, setter: "private")] [SerializableField(5, setter: "private")]
private DateTime _shield; private DateTime _shield;

View file

@ -359,14 +359,14 @@ namespace Server.Factions
{ {
if (m_Mobile.InRange( m, 1 )) if (m_Mobile.InRange( m, 1 ))
RunFrom( m ); RunFrom( m );
else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo(m, 1)) else if (!m_Mobile.InRange( m, m_Mobile.RangeFight > 2 ? m_Mobile.RangeFight : 2 ) && !MoveTo( m, true, 1 ))
OnFailedMove(); OnFailedMove();
} }
else else
{*/ {*/
if (!Mobile.InRange(m, Mobile.RangeFight)) if (!Mobile.InRange(m, Mobile.RangeFight))
{ {
if (!MoveTo(m, 1)) if (!MoveTo(m, true, 1))
{ {
OnFailedMove(); OnFailedMove();
} }

View file

@ -219,7 +219,7 @@ namespace Server.Engines.Harvest
} }
else else
{ {
bonusItem?.Delete(); item.Delete();
} }
} }

View file

@ -162,15 +162,10 @@ namespace Server.Items
} }
} }
[SerializationGenerator(1)] [SerializationGenerator(0)]
public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution public partial class PuzzleChestSolutionAndTime : PuzzleChestSolution
{ {
private void MigrateFrom(V0Content content) [DeltaDateTime]
{
_when = content.When;
}
[AnchoredDateTime]
[SerializableField(0)] [SerializableField(0)]
private DateTime _when; private DateTime _when;
@ -242,12 +237,16 @@ namespace Server.Items
} }
} }
[SerializableField(0, fieldChanged: nameof(OnSolutionChanged))] [SerializableProperty(0)]
private PuzzleChestSolution _solution; public PuzzleChestSolution Solution
private void OnSolutionChanged(PuzzleChestSolution oldValue, PuzzleChestSolution newValue)
{ {
InitHints(); get => _solution;
set
{
_solution = value;
InitHints();
this.MarkDirty();
}
} }
public PuzzleChestCylinder FirstHint public PuzzleChestCylinder FirstHint
@ -551,14 +550,21 @@ namespace Server.Items
return; return;
} }
using var toDelete = PooledRefQueue<Mobile>.Create();
foreach (var (key, value) in _guesses) foreach (var (key, value) in _guesses)
{ {
if (Core.Now - value.When > CleanupTime) if (Core.Now - value.When > CleanupTime)
{ {
_guesses.Remove(key); toDelete.Enqueue(key);
} }
} }
while (toDelete.Count > 0)
{
_guesses.Remove(toDelete.Dequeue());
}
if (_guesses.Count == 0) if (_guesses.Count == 0)
{ {
_guesses = null; _guesses = null;

View file

@ -116,9 +116,10 @@ namespace Server.Engines.MLQuests.Gumps
private static void CloseCurrent(NetState ns) private static void CloseCurrent(NetState ns)
{ {
if (m_Pending.Remove(ns, out var state)) if (m_Pending.TryGetValue(ns, out var state))
{ {
state._timeoutToken.Cancel(); state._timeoutToken.Cancel();
m_Pending.Remove(ns);
} }
ns.SendCloseRaceChanger(); ns.SendCloseRaceChanger();

View file

@ -19,7 +19,7 @@ namespace Server.Engines.MLQuests
{ {
base.Serialize(writer); base.Serialize(writer);
writer.Write(3); // version writer.Write(2); // version
writer.Write(MLQuestSystem.Contexts.Count); writer.Write(MLQuestSystem.Contexts.Count);
foreach (var context in MLQuestSystem.Contexts.Values) foreach (var context in MLQuestSystem.Contexts.Values)

View file

@ -119,7 +119,7 @@ namespace Server.Engines.MLQuests.Objectives
if (IsTimed) if (IsTimed)
{ {
writer.Write(true); writer.Write(true);
writer.WriteAnchoredTime(EndTime); writer.WriteDeltaTime(EndTime);
} }
else else
{ {
@ -135,7 +135,7 @@ namespace Server.Engines.MLQuests.Objectives
{ {
if (reader.ReadBool()) if (reader.ReadBool())
{ {
var endTime = version >= 3 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); var endTime = reader.ReadDeltaTime();
if (objInstance != null) if (objInstance != null)
{ {

View file

@ -727,14 +727,20 @@ public sealed class StepCache
var window = MissPromotionWindowMs; var window = MissPromotionWindowMs;
var beforeCount = _chunkMissTracker.Count; var beforeCount = _chunkMissTracker.Count;
using var toRemove = PooledRefQueue<long>.Create();
foreach (var kvp in _chunkMissTracker) foreach (var kvp in _chunkMissTracker)
{ {
if (now - kvp.Value.LastMissTickStamp > window) if (now - kvp.Value.LastMissTickStamp > window)
{ {
_chunkMissTracker.Remove(kvp.Key); toRemove.Enqueue(kvp.Key);
} }
} }
while (toRemove.Count > 0)
{
_chunkMissTracker.Remove(toRemove.Dequeue());
}
if (_chunkMissTracker.Count == beforeCount) if (_chunkMissTracker.Count == beforeCount)
{ {
_chunkMissTracker.Clear(); _chunkMissTracker.Clear();

View file

@ -1,5 +1,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using Server.Engines.Pathing;
using Server.Engines.Pathing.Cache;
using Server.Items; using Server.Items;
using Server.PathAlgorithms; using Server.PathAlgorithms;
using Server.Spells; using Server.Spells;

Some files were not shown because too many files have changed in this diff Show more