Compare commits

..

51 commits

Author SHA1 Message Date
25a2aa03c5 #W# Update: added Distribution/Data/Files to gitignore.
Some checks are pending
Build / Build (MacOS 15) (push) Waiting to run
Build / Build (MacOS 26) (push) Waiting to run
Build / Build (AlmaLinux 10) (push) Waiting to run
Build / Build (Debian 12) (push) Waiting to run
Build / Build (Debian 13) (push) Waiting to run
Build / Build (Fedora 44) (push) Waiting to run
Build / Build (CentOS 10 Stream) (push) Waiting to run
Build / Build (CentOS 9 Stream) (push) Waiting to run
Build / Build (Ubuntu 26) (push) Waiting to run
Build / Build (Ubuntu 22) (push) Waiting to run
Build / Build (Ubuntu 24) (push) Waiting to run
2026-09-02 10:16:24 -04:00
Kamron Batman
e52d54b7da
perf: keep damage entries in an inline intrusive list (#2605)
## Summary

`Mobile.DamageEntries` was a `List<DamageEntry>` allocated for every mobile, including the ~99% that never take damage. It is now an inline `ValueLinkList<DamageEntry>` (24 bytes in the `Mobile` object, no separate allocation) ordered least recent → most recent.

- `DamageEntry` implements `IValueLinkListNode<DamageEntry>`.
- `RegisterDamage` moves the entry to the tail in O(1) instead of `Remove` + `Add` on a list.
- Expired entries are always a head prefix, so pruning walks from the head and stops at the first live entry. The `DamageEntries` getter prunes on access.
- `DamageEntries` is exposed as `ref readonly`; enumerate with `foreach` or `.ByDescending()`. Mutation goes through `RegisterDamage` / `ClearDamageEntries`.
- `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` take `in ValueLinkList<DamageEntry>`; all callers compile unchanged. Files that `foreach` over `DamageEntries` need `using Server.Collections;` for the enumerator extension.
- RunUO migration docs (`dev-docs/runuo-migration-docs/09`, `11`) and the `migrate-items-mobiles` skill document the change.

Saves one object and 16 bytes per mobile (~8 MB and 500k gen2 objects on a 500k world). Second of three PRs from the lazy per-mobile collections design (first: #2604). Branched from `main`; the two diffs touch disjoint hunks of `Mobile.cs`.

## Breaking change

- `Mobile.DamageEntries` is no longer a `List<DamageEntry>`. Indexing, `.Clear()`, `.Add()`, `.Remove()` no longer compile; use `foreach`, `.ByDescending()`, `.Count`, `ClearDamageEntries()`, and `RegisterDamage`. Calling a `ValueLinkList` mutator on the `ref readonly` property compiles but operates on a copy while still unlinking the real nodes; do not.
- `BaseCreature.GetLootingRights` and `BaseCreature.ComputeBonusDamage` signatures changed to `(in ValueLinkList<DamageEntry>, …)`.

Save format is untouched: damage entries are not serialized.

## Behavior

Recency order, `allowSelf`, tie-breaking in `FindMostTotal`/`FindLeastTotal` (most recent wins), `Responsible` accounting, and loot-rights ordering are unchanged and covered by the new `DamageEntryTests` and `LootingRightsTests`.

## Testing

- `dotnet build -c Release` clean.
- New `DamageEntryTests` and `LootingRightsTests` plus full `Server.Tests` and `UOContent.Tests`.
2026-09-01 23:25:14 -07:00
Kamron Batman
708a354337
perf: stop allocating stat/skill mod lists for every mobile (#2604)
## Summary

`_statMods` and `_skillMods` are created lazily by `AddStatMod` / `AddSkillMod` and nulled when they empty, and every reader already null-checks. The eager `new List<T>()` in `DefaultMobileInit` and `Deserialize` therefore allocated two dead 32-byte objects for every mobile. On a ~500k-mobile world that is ~32 MB and 1M gen2 objects that hold nothing.

- Removes the four eager allocations.
- Removes the `StatMods` accessor (no references).
- Documents `SkillMods` as `null` when no mods are active (its one caller in `Skills.cs` already checks).

First of three PRs from the lazy per-mobile collections design; `DamageEntries` and `Aggressors`/`Aggressed` follow separately.

## Breaking change

- `Mobile.SkillMods` may now be `null` (it was never null after construction before). External callers that enumerate it or read `.Count` must null-check.
- `Mobile.StatMods` is removed. Use `GetStatMod(name)` / `AddStatMod` / `RemoveStatMod`.

Save format is untouched: neither list is serialized.

## Testing

- `dotnet build -c Release` clean.
- New `MobileLazyModListTests` plus full `Server.Tests` (840) and `UOContent.Tests` (756).
2026-09-01 23:23:32 -07:00
Kamron Batman
d3bf283e2d
feat: event-driven target acquisition with a reaction-time gradient (#2601)
Fixes walk-up aggro latency (up to a full 10 s of obliviousness) and hardens the reacquire gate so no state can silence acquisition, while turning `AcquireOnApproach` into the reaction-time knob for future per-creature intelligence tuning.

### Why

`AcquireFocusMob` re-armed the 10 s `ReacquireDelay` **before** scanning, success or failure. A creature that scanned an empty room was blind for 10 s to a player walking up — walk-up aggro latency was uniform in 0..10 s. Waking from sector sleep stacked the AI timer's 0–3 s construction stagger on top. And `NextReacquireTime` is not serialized: on hosts whose tick counter starts negative (GCP pass-through), the 0 default blocked **all** acquisition shard-wide after a restart until the counter crossed zero.

### What

**Event-driven reaction — `AcquireOnApproachDelay` (the intelligence gradient)**
- The paragon `AcquireOnApproach` bool becomes a `TimeSpan` on every creature: an enemy moving inside `AcquireOnApproachRange` (10 for all creatures — on-screen reactive aggro; the periodic scan keeps the wide `RangePerception` sweep) *clamps* the next scan to at most the delay. Repeated steps cannot shorten it further — one scan per delay period, not per step or think.
- `Zero` (paragons) also prods the AI timer: the ranked scan engages within a wheel turn — the old snap, minus the special-cased engage path. The target now comes from the normal FightMode ranking instead of whichever mobile happened to move, and the `Combatant == null` guard stops re-engage spam.
- The 2 s default reads as "took a beat to notice you"; larger values are dumber; `ReacquireDelay` alone is the oblivious floor. Mover checks are the approach logic's `IsEnemy` + `CanBeHarmful` (so pets count and hidden movers are excluded via `CanSee`), with `IsEnemy` first to cheaply reject same-team wild creatures wandering past. The check rides the `OnMovement` callback every step already pays for — no polling added.

**Gate correctness**
- Every scan re-arms the full `ReacquireDelay`, success or failure (classic semantics; reaction time is the approach path, not the poll).
- Self-healing by construction: a deadline further out than `ReacquireDelay` is an illegal state and reads as open — no wedged or wrapped value can silence acquisition beyond one delay period.
- `NextReacquireTime` is seeded from a live tick on deserialize (the GCP negative-tick blackout).

**AI timer wake**
- Activation (sector wake, spawn, resurrection) starts within a 0–256 ms spread instead of the 0–3 s construction stagger, which read as lag.
- The stagger's real job — keeping same-speed cohorts out of lock-step (the RunUO town artifact) — is now a zero-mean ±period/8 jitter on each **idle** think, so phases random-walk apart within seconds and can never re-lock. Instrumentation showed why a one-shot spread can't do this job: the timer wheel fires within ±1 ms, so with 10 creatures on a 500 ms period some pair collides on nearly the same phase ~75% of the time (birthday paradox) and then steps in the same loop iteration *forever*. Jitter is scoped to passive speed: engaged cadence stays exact, since pursuit timing anchors to real step times.

**Debug**
- The `AcquireFocusMob` scan message no longer re-arms the shared 5 s debug cooldown, which swallowed every AI's "I have detected X" transition line.

**API change** for custom scripts: `AcquireOnApproach` (bool) → `AcquireOnApproachDelay` (TimeSpan). Documented in `content-patterns.md` § Target Acquisition, `runuo-migration-docs/09` + `11`, and the migration skill checklist.

### Tests

`AcquisitionTests`: both scan outcomes honor `ReacquireDelay`; a 60 s-wedged gate still acquires; enemy movement clamps the deadline (same-team wild movers and out-of-range movers ignored); repeated movement cannot shorten below the delay; `Zero` opens the gate and prods without a direct engage. Full suite: 755 UOContent green.
2026-09-01 20:42:15 -07:00
Kamron Batman
547c2ea0fa
fix: Fixes tick count wrap-around in movement throttle, and eliminates more allocations in NetState (#2603)
## Summary

Removes the per-tick allocation in the movement throttle, fixes tick-count wrap-around bugs in the throttle and RTT probe state, and trims per-connection allocations and dead fields in `NetState`.

## Movement throttle

- **No more per-tick `List<NetState>` snapshot.** `ProcessAllQueues()` iterates the `HashSet` directly and removes drained or disconnected states in place. `HashSet<T>.Remove` does not invalidate enumerators on .NET Core 3.0+ (verified on 10.0.11); only inserting a *new* member does, and the only `Add` is in the packet handler, which never nests with `Slice()`. The eager `Remove` calls in `RejectAndReset`, `ClearQueue`, and `ProcessMovementQueue` are gone; membership is reconciled once per tick from `_hasQueuedMovements`.
- **Debug logging** is now gated solely by the per-connection `NetState.MovementLogging` flag. The global `movementThrottle.debugLogging` setting is removed.
- **New settings**: `movementThrottle.maxRttBonus`, `movementThrottle.maxChainGap`, and `movementThrottle.speedHackNotificationCooldown` were fields with no config binding.

## Tick-count wrap-around

All comparisons are now in subtraction form and no tick field uses zero as a sentinel:

- `now < _nextMovementTime` in the queue drain loop → `now - _nextMovementTime < 0`.
- `_lastMovementRecordTime > 0`, `_lastSpeedHackNotification`, `_rttProbeTime > 0`, and `_nextRttProbe == 0` sentinels replaced with `_hasMovementRecord`, `_speedHackNotified`, `_rttProbePending`, and a seeded `_nextRttProbe`.
- `_lastQueueDepthCheck` and `_movementWindowStart` are seeded from `Core.TickCount` at construction and on reset instead of zero.

User-visible effects of the old code: on hosts with pass-through counters (GCP) movement history never recorded and speed hack detection was silently off; on every host, staff speed hack notifications were suppressed until `Core.TickCount` exceeded the five-minute cooldown.

## NetState

- `Instances` returns `HashSet<NetState>` again so engine-internal `foreach` uses the struct enumerator instead of boxing through `IReadOnlySet<T>`.
- Removed `_sustainedQueueDepth` (declared and zeroed since #2266, never read), `_lastRtt` (now derived as `LastRtt` from the newest history slot), and `_rttProbeTimestampHiRes` (only fed one debug log line). 20 bytes per connection.
- `HuePickers`, `Menus`, and `Trades` are lazily created instead of allocating three lists per connection, including every login-server connection that dies on shard select. `Trades` is released when it empties. All helpers and the `HuePickerResponse` / `MenuResponse` handlers are null-tolerant; the trade cancel loops keep their `i < Count` guards because `SecureTrade.Cancel()` runs virtual item hooks that can re-enter the same list.

## Testing

- `dotnet build -c Release` clean.
- All MovementThrottle tests pass (27), plus the Trade / Menu / HuePicker / NetState tests (32).
2026-09-01 20:25:20 -07:00
Sergi Rosell
c9875e7f64
fix: delete the bonus item, not the primary yield, when the bonus cannot be placed (#2602) 2026-08-31 08:46:20 -07:00
Kamron Batman
e07416902a
feat: derive the Running bit from the step pace and fix step-pacing bursts (#2599)
Stacked on #2594. Fixes jerky creature movement (lich / Fast-bucket melee chases) by choosing the client animation flag from the actual step pace instead of a caller-supplied `run` argument, and fixes three step-pacing defects in the move budget found while verifying it with paired server/client traces.

### Why

The `Direction.Running` bit does nothing for creatures server-side (`Mobile.OnMove` reads it only for the player throttle and stealth reveal). Its whole effect is on the client, which animates each step over a fixed time selected by that bit: walk 400 ms / run 200 ms on foot, 200 / 100 ms mounted. ClassicUO queues up to 5 steps and *drops* the sixth, so a creature stepping every 300 ms while flagged as walking backs the queue up until it snaps forward — the observed jerk.

The `run` argument never carried the one fact that matters (the step interval). RunUO passed `true` in combat / `false` for pets and gated it on `dist > 5`; #2271 flipped every combat site to `false`; pets passed `currentDistance > 2`. None of that is a coherent signal.

### What

**Pace-derived run flag**
- `BaseAI.ShouldRun()`: run iff the effective step delay (move clock + badly-hurt inflation) is shorter than `Movement.WalkFootDelay` / `WalkMountDelay` (mounted or flying) — with a continuity rule: an *isolated* step (taken after standing at least a walk interval) goes out as a walk, because the client renders each step alone and a lone run-flagged step is a 200 ms dart. Only a continuing cadence flags run; a true sprinter (pace under the run interpolation) always runs, since a walk-rendered first step would flood the client's 5-step queue. This reproduces RunUO's close-in feel (its `dist > 5` gate) from first principles.
- `DoMoveImpl` stamps the bit; it is the single place the flag is set.
- `run` removed from `MoveTo`, `WalkMobileRange`, `ApproachTarget`, `MoveToPoint`, `MoveToWithGroup`, `MoveToWithCollisionAvoidance`, the move intent, and `PathFollower.Follow`. All 35 call sites updated. **API change** for custom scripts — documented in the RunUO migration docs (`09-items-mobiles-creatures.md`, `11-api-reference.md`) and `content-patterns.md` § Creature Speeds.

**Move-budget pacing fixes** (each confirmed by UTC-aligned server/client step traces)
- A stall no longer banks catch-up steps: the budget's snap-to-now released up to three steps in ~300 ms when a creature resumed chasing after standing beside its target — rendered as a teleport.
- Debt accrual removed entirely: a step landing sub-period late (think-grid vs budget misalignment during reactive mirroring) kept the remainder and fired a follow-up ~100 ms later — a dart pair. `ConsumeMoveBudget` now paces every step from when it was actually taken; in continuous pursuit the move-wake lands within wheel resolution of the deadline, so the cost is single-digit-ms drift.
- Net effect: a creature can never step faster than its pace, verified across a full chase session (zero sub-pace steps; metronomic 350 ms cadence for a 0.3 s lich).

- Test fixture now runs `Movement.Configure()` (the walk delays were 0 in tests).

### Accepted trade-off

Animal (LOW group) bodies without a run animation slide on their stand frames when flagged as running. Most are slow enough to stay flagged as walking; the client-side fallback is in ClassicUO/ClassicUO#1930.

### Tests

`RunFlagTests`: foot thresholds (0.3 / 0.125 run; 0.4 / 0.45 / 1.05 walk), flying uses the mount threshold, badly-hurt inflation flips a 0.35 s creature back to walk, a real `DoMove` stamps the bit, isolated steps drop to walk (sprinters keep running), a stall restarts the cadence with no banked steps, and a late step earns no quicker follow-up. Full suite: 837 Server + 747 UOContent green.
2026-08-30 16:48:52 -07:00
Kamron Batman
4420872b22
fix: pet obedience pacing, stale AI wake rescheduling, and Guard order persistence through combat (#2594)
Closes #2593. Closes #2595.

Two related pet-AI fixes: the post-#2591 pacing/wake regression (#2593), and the guard order silently converting to Attack during combat (#2595). Root-cause analyses are in the issues.

## #2593 — pets follow slowly; stale AITimer wakes

**Why pets slowed:**
- The per-step budget grew from **half a think interval** (`CurrentSpeed * 500`) to the full RunUO-parity move table (`CurrentMoveSpeed * 1000`). Medium-bucket pets (Horse, Dog, most tamables): passiveMove **1.05s/step**.
- Pet order speed depended on stale `Warmode`: `HandleGuardOrder` set it once, but `OnCombatantChange` clears it whenever the combatant drops, so obedience ran active or passive **by combat history** — usually passive. Net: Guard/Come at ~1.05s/step (~2.1x slower than pre-#2591), vs a player running at 0.1–0.2s/step.
- The AITimer never rescheduled its pending wheel entry: the wheel reads `Interval` only after the next fire, so a speed-up or a fresh order (`Activate()` no-ops while running) waited out the stale wake — up to a full passive think, stacked on the residual move budget on Guard → Follow.

**What changed:**
- **Order handlers own obedience speed** (RunUO `OnCurrentOrderChanged`/`DoOrder*` parity, re-derived continuously): issuing a movement order (Come/Follow/Guard/Attack) sets the **active** think clock, resting orders (Stay/None/Transfer) set passive, and the guard/follow peaceful branches write **RunUO's AOS `CurrentSpeed = 0.1` sprint** — RunUO's guard else-branch had the identical write as follow. The bespoke 0.1 fuses to both clocks through #2591's existing classification, so `CurrentMoveSpeed` stays **pure herding + classification** with no obedience special case, and `DoMoveImpl`'s per-step flip skips obeying pets (their handler owns the pace) and loses its old follow-only 0.1 write. Combat still re-derives organically via warmode/combatant.
- **`AITimer`**: tracks the pending wake and reschedules (`Stop`, `Delay` = remaining, `Start`) when a speed-up or fresh order moves the earliest deadline up; changes inside a tick still flow through `ScheduleNext`. New `Prod()` wakes the AI immediately on player commands — including from a stopped timer, so stable claims no longer wait out the random construction stagger. Sector/spawn wakes keep the stagger. Spam-safe: a prodded think grants reaction, never action — steps/swings/casts/abilities are gated by their own budgets and timers.

The residual move budget is deliberately **not** cleared on order change — that would let order-spam macros grant free steps. Deadline changes reschedule the timer; rate changes take effect at the next deadline computation.

## #2595 — Guard order converts to Attack during combat

**Why:** `FindCombatant()` set `ControlOrder = OrderType.Attack` when engaging, so a guarding pet left the Guard order for the whole fight: OPL tags wiped (pet `1080078` + master `501129`), no retargeting (`DoOrderAttack` locks its target), `TeleportPets` left the pet behind on recall/gate, and every engage→kill→resume cycle replayed the guard flourish.

**What changed:**
- **`FindGuardTarget()`** (was `FindCombatant`): a pure selector — prefers the aggressor **closest to the master** (RunUO guard parity, dynamic retargeting to protect the owner), keeps the current combatant unless a strictly closer one exists, and never mutates order state. `DoOrderGuard` engages through it while **staying in Guard** the whole fight.
- **Persistent-order semantics** (the ModernUO improvement over RunUO): an explicit `all attack` completes → `ResumePersistentOrder()` returns to Guard → the guard scan engages remaining threats in-order. The Attack-chaining fallback (`FightMode.Closest/Aggressor`) now applies only to non-guard persistent orders. Resuming Guard no longer replays the sound/"is now guarding you" message.
- **Peaceful guard stands down deterministically** (`Warmode`/`Combatant`/`FocusMob` cleared) and returns to the master at the RunUO sprint (see above); at the master's side it stays organically active.
- **`WalkMobileRange` honors the caller's run flag** (the internal hardcoded `dist > 5` gate silently overrode it). Run is animation-only server-side; the only callers passing anything but `false` — follow, guard, clone — gate on their own thresholds.

## Resulting behavior (Medium-bucket pet)

| Scenario | Broken | This PR |
|---|---|---|
| Guard trailing master (AOS) | ~1.05s/step, think-grid quantized | 0.1s/step sprint (RunUO parity), smooth move wakes |
| Guard during combat | order flips to Attack; tags lost; no retarget; left behind on recall | stays Guard; retargets to master's closest aggressor; teleports with master |
| `all attack` while guarding | resume spams guard flourish per kill; chains into Attack | resumes Guard silently; guard scan takes over |
| Come / friend-follow | 1.05s/step | activeMove 0.45s/step (≈ pre-#2591 feel) |
| Guard → Follow reaction | up to ~1.5s dead time | think within one wheel turn |
| Follow master (AOS sprint) | 0.1s/step | 0.1s/step (unchanged) |
| Wild creature chase | RunUO-parity move table | unchanged |

Also documents two contracts this work leaned on: the `ControlOrder` setter deliberately fires on every assignment (a reissued order is a command — retarget/break-off/re-anchor), and `OnThink`/`MonsterAbility` must be excess-call tolerant (`dev-docs/content-patterns.md` § OnThink: the excess-call contract).

## Testing

- Full suite passes (1570: 837 Server + 733 UOContent).
- `PetPacingTests`: order-issue think-clock parity, follow-master sprint via Obey, guard organically active at the master's side, combat-chase and herding boundaries, plus two deterministic timer-wheel tests (8ms-lockstep slicing) proving a fresh order and a mid-wait speed-up wake the AI promptly.
- `GuardOrderTests`: engage keeps the Guard order; retargets to the aggressor closest to the master; explicit attack resumes Guard without chaining into Attack; peaceful guard stands down. Setup self-validates LOS/terrain.
- `GuardFollowTests`: guard-following registers a move intent, steps toward the master, sprints at 0.1 under AOS (per-step flip must not undo it), and runs active pre-AOS.
- All behavioral tests were written first and failed for the documented reasons.
2026-08-30 16:39:29 -07:00
Tald0r
38c74a968b
fix(regions): correct end Z coordinate assignment in InitRectangles (#2597)
The `ez` variable was incorrectly assigned `rect.End.X` instead of `rect.End.Z`, causing incorrect rectangle processing in region initialization.
2026-08-27 06:51:42 -07:00
Kamron Batman
e7f85d404d
feat: Adds independent think/move clocks for creature AI to fix speed (#2591)
Splits creature speed into two clocks so movement pace can be tuned without touching reaction time:

- **Think clock** — `ActiveSpeed`/`PassiveSpeed`/`CurrentSpeed`: seconds per AI decision. Unchanged in meaning, storage, and cadence.
- **Move clock** — `ActiveMoveSpeed`/`PassiveMoveSpeed` (+ resolved `CurrentMoveSpeed`): seconds per step. `0` = inherit the matching think value.

### How

- Move speeds come from optional `activeMove`/`passiveMove` in `npc-speeds.json`, are `[props`-tunable per instance (set `0` to re-inherit), and serialize (BaseCreature v22).
- `SetSpeed()` keeps its legacy one-clock semantics — sets the think clock **and clears move overrides** — so existing callers cannot half-configure a creature. `SetMoveSpeed()`/`ClearMoveSpeed()` configure movement explicitly; `ScaleMoveSpeed()` scales overrides for buffs.
- `CurrentMoveSpeed` is derived by classifying `CurrentSpeed`: a verbatim active/passive think value maps to the matching move value; a bespoke pace written directly (mount boosts, follow sprint) stays fused to both clocks. External `CurrentSpeed` writers need no changes.
- `AITimer` schedules the earlier of the two deadlines. Decisions run at the think cadence exactly as before; while a pursuit/investigation is live, the timer also wakes when the movement budget elapses and advances one step with no decisions. Steps no longer snap to the think grid, so any step delay paces smoothly on the 8ms wheel. A blocked creature schedules no move wakes.
- The movement budget is RunUO's `m_NextMove` accumulate-and-clamp at a full step, so long-run pacing averages `CurrentMoveSpeed` exactly.

### Behavior changes

- **`npc-speeds.json` buckets get RunUO `TransformMoveDelay`-parity move values**: creatures step at RunUO pace while thinking/reacting at current speed. The situational +0.1/+0.2 offsets are deliberately omitted.
- **Existing saves migrate on load**: a pre-v22 creature whose think speeds still match its npc-speeds entry (never hand-tuned) adopts the table's move values — worlds and pets pick up the new pacing without a respawn. Tuned creatures keep movement inheriting their think clock.
- **Paragons scale movement by `SpeedBuff` (1.2x)**: RunUO had no deliberate policy here — dividing by 1.2 knocked most speeds off `TransformMoveDelay`'s exact-equality table (raw pass-through, 2x+ faster), while 0.3/0.6 creatures landed back on it for ~1.33x. This applies the uniform 1.2x the buff always claimed. UnConvert snaps speeds back to exact table values within 1e-4 — /1.2 then ×1.2 drifts 0.45 and 0.9 by an ulp, which would read as hand-tuned (and defeat a future skip-table-conformant-values serialization pass); tuned speeds keep.
- **Herding paces the movement clock**: the old `CurrentSpeed` getter hack is gone. A herded creature walks at a fixed 0.3s/step — RunUO's forced pace, without its `TransformMoveDelay` inflation to 0.6 — so herding is never penalized by a slow creature. Thinking is untouched, and `CheckHerding` walks through `MoveToPoint`, so herded creatures path around obstacles.
- **Badly-hurt slowdown now inflates the step delay only** (RunUO parity), computed from the base each step. Previously it wrote `CurrentSpeed = CurrentSpeed + 0.05..0.15` back on every successful step — compounding unboundedly while hurt and slowing decisions too.
- Removes the vestigial `MoveSpeedMod` (never read, written, or serialized).
- With no bucket or per-instance move values, both clocks carry identical values and creatures pace as before.

### Testing

- Full suite passes (1557, including 12 new `MoveSpeedTests`: resolution classes, `SetSpeed` clearing, `0`-re-inherit, v22 round-trip with exact-consumption check, save migration adopt/skip, buff scale/snap, herding).
- In-game verified via local diagnostics build (per-step budget tracing): steady 700ms step cadence on a 0.3s think grid with one-step catch-up after idle, think grid unperturbed by move wakes.
2026-08-23 10:19:59 -07:00
Kamron Batman
8e39da2810
fix: creatures track and chase targets reliably around corners (#2590)
### Summary

Fixes the long-standing reports of monsters losing track of players who run around a corner ("Is monster AI not using pathfinding? It seems to be LOS blocked by statics"). Root-cause investigation compared current behavior against RunUO line-by-line and traced the regressions through the AI overhaul era (#2232, #2246, #2379, #2401, #2461).

### Root causes and fixes

1. **Movement contract** — `MoveTo`/`ApproachTarget` returned false on every healthy mid-chase tick (true only on arrival), so MeleeAI's RunUO-inherited *"move failed and beyond RangePerception+1 → Guard"* clause — which RunUO only evaluated on genuine blockage — fired **every tick of every chase**. A mounted player trivially opens 17 tiles at a corner, the monster guards, Guard nulls the combatant, and re-acquisition is LOS-gated — unrecoverable through a wall. Movement now reports failure only on genuine failure (no step taken with no working path, or approach give-up). ArcherAI's equivalent clause moves to the hard leash.

2. **Last-known-position pursuit** — while a combatant is in LOS its position is recorded each think tick. When the target vanishes (corner, hiding, recall), the creature walks to the last-seen spot, stands guard there ~10s (restoring RunUO's guard grace, which had decayed to a single tick since #2246), and **re-engages instantly** if the same target re-enters view — bypassing the 10s reacquire throttle.

3. **`ChaseLeashRange`** — new virtual on BaseCreature (default `RangePerception * 2` = 32 tiles) replaces the inline `RangePerception * 3` (48) in Melee/Mage/Archer AI. Per-creature tunable via `[props`.

4. **Group movement demoted to a crowding refinement** — previously any uncontrolled creature with one ally within 8 tiles on the same target used greedy ring-stepping for the *entire* chase, with wall-slides counted as success, never invoking the pathfinder — the "aggroed but won't come around the corner" symptom for spawn groups. It now engages only near the target when allies actually contest the ring, and blocked/wall-slid steps escalate to the pathfinding approach primitive.

5. **Mages close distance on broken LOS** — a mage within casting range but LOS-blocked by geometry stood at the wall holding a spell target until the 60s combatant expiry (ProcessTarget short-circuits Think and its RunTo stands off at RangeFight). Geometry-blocked mages now close in until LOS returns, both pre-cast and while holding a target. Hidden targets (CanSee) and poison-cure priority unchanged. The new movement contract also stops the constant spurious `OnFailedMove` teleport rolls mid-chase.

6. **Move budget: one actual step per AI tick** — nothing advanced `NextMove` on a normal step (RunUO's `m_NextMove` budget was lost), so code paths attempting several moves in one think tick could cross multiple tiles at once — visible as "warping" when crowded creatures jockey for position. A successful step now consumes a half-step budget (floor 50ms): blocks intra-tick double moves, stays safely below the timer interval so legitimate next-tick moves are never jitter-throttled, and does not reintroduce `TransformMoveDelay` inflation. Blocked attempts consume nothing, so retry ladders (repath-and-step, the collision fan) are unaffected. `CanMoveNow` is also wraparound-safe now.

### Reference behavior

RunUO requires LOS to *acquire* a target and to *land* a hit or spell — never to *continue* a chase (its MeleeAI LOS bail-out is literally commented out in stock code). Chases drop only on: target hidden, target dead/off-map, beyond `RangePerception * 3`, 60s without combat interaction, or blocked movement while far away. This PR restores those semantics while adding the last-known-position investigation on top. NPC run flags are untouched — pace is AI-timer-driven and most NPC art has no run animation.
2026-08-23 01:13:00 -07:00
Kamron Batman
2935eafe24
feat: convert all delta-time serialization to anchored time (#2589)
## Summary

Phase 3 of the anchored-time work: **every actively-written delta-time value in the engine now stores an anchored timestamp** — absolute on the wire, shifted forward by the downtime at load. Remaining time survives restarts (as delta did), and unlike delta, the bytes do not change on every save, so an idle world serializes identically save after save.

The answer to "is it possible everywhere": **yes** — including the one case that looked impossible.

## The GenericPersistence problem, solved

`GenericPersistence` bins (`Virtues.bin`, `StealableArtifacts.bin`, …) are raw payloads with no idx header, so they have no anchor of their own — anchored reads there would silently apply zero shift. But the anchor is a property of the **save**, not the file: every file in one save shares one `World.SaveStartTime`, and `Persistence.Load` reads **all** entity indexes (phase 1) before **any** persistence payload (phase 2). So the idx v5 header stamps a save-wide `World.LoadTimeShift`, and generic persistence readers inherit it. No file-format change, no per-bin header, old bins unaffected.

## Converted

- **Item v10 → v11**: `LastMoved` — previously whole-minute delta, rewritten every save for every item, the single largest source of idle-save churn — and `DecayResetTime` (retiring the TODO from #2583). **Mobile v37 → v38**: the three stat-gain stamps. **BaseCreature v20 → v21**: `SummonEnd`.
- **17 code-generated classes** (`[DeltaDateTime]` → `[AnchoredDateTime]`, version bump + `MigrateFrom` each): the five field spells, TransientItem, VirtueContext (×7 fields), PuzzleChestSolutionAndTime, BaseCamp, BaseBoat, RentedVendor, PlayerVendor, Ethics Player, Sheep, StarRoomGate, ChampionSpawn (×3), Corpse (`TimeOfDeath`, v19). The `MigrateFrom` bodies were generated from each class's current migration schema and are compiler-verified; VirtueContext's save-flagged nullables fall back to the same defaults the old deserialize left in place. Corpse's six migrations moved to a new `Corpse.Migrations.cs`.
- **Hand-written sites**: StealableArtifacts (v2), VendorInventory (v1), ML quest objectives (persistence v3) — each gated on its own version.

**Not converted, deliberately**: the ~25 read-only `ReadDeltaTime` sites in legacy version fallbacks and migration replays — they decode existing old bytes and must never change. `[DeltaDateTime]`/`WriteDeltaTime` remain available for them.

## Verification

- Build 0 errors / 0 warnings; **837 + 708 tests green**.
- Schema regeneration produced exactly the 17 expected new `vN.json` files (all `AnchoredTime` rule args), nothing else touched.
- **New acceptance tests** pin the point of the whole effort: serializing the same item at two save times **5 hours apart produces byte-identical output**, and `LastMoved`/`DecayResetTime` round-trip **exactly** at sub-minute precision (the old minutes encoding destroyed both properties).

## Notes for review

- `LastMoved` grows from a 1–3 byte encoded minutes value to 8-byte ticks per item — the price of byte-stability; it repays itself in incremental-save behavior since unchanged items now produce unchanged bytes.
- BaseEscortable-style semantics are unchanged: anchored shift preserves *remaining* time exactly, the same contract delta provided, so no gameplay-visible behavior changes — deadlines simply stop being consumed by downtime that delta already protected against, now with stable bytes.

## Enforcement

`WriteDeltaTime` is now `[Obsolete]` (interface + implementation). With the repo's warnings-as-errors, any new delta-time write — hand-written or emitted by a still-unconverted `[DeltaDateTime]` field — fails the build, with the migration instructions in the message. That the full solution still builds with **zero warnings** is itself the proof no active delta writer survived the conversion. `ReadDeltaTime` deliberately stays un-attributed: its remaining callers decode existing old bytes and are correct forever; its XML docs now state the legacy-decode-only contract.
2026-08-22 19:34:43 -07:00
Kamron Batman
b992c7b955
docs: update serialization docs and skills for generator v4 (#2588)
## Summary

Brings every serialization-related doc, skill, and the CLAUDE.md rule in line with generator **v4** (adopted in #2586/#2587). No code changes.

**Updated surface, everywhere it was referenced:**
- `[SerializableFieldSaveFlag(order)]` / `[SerializableFieldDefault(order)]` → `[SaveFlag(nameof(Should), nameof(Default))]` on the field (second method optional).
- `[TimerDrift]` + `[DeserializeTimerField(order)]` → `[DeserializeTimer(nameof(Method), wallClock)]` on the field, with the anchored-time semantics spelled out: drifting by default (downtime preserves the remaining delay, idle saves byte-stable), `wallClock: true` for absolute deadlines, restart method invoked **only when a timer was running** (no sentinel), and the timer `MigrateFrom` pattern (`XxxNext`/`XxxDelay`) for wire-format changes.
- New `[SerializableField]` documentation: the real signature (the documented `saveIf` parameter never existed) plus the setter hooks — `allowFieldChange` (`bool Method(ref T value)`: coerce/veto before assignment) and `fieldChanged` (`void Method(T oldValue, T newValue)` after) — with the generated pipeline and the SG3015/SG3018 guardrails.
- `[SerializableProperty]` guidance narrowed to its remaining purpose: custom getters and setter semantics the hooks cannot express.
- `[AnchoredDateTime]` documented alongside `[DeltaDateTime]` (now marked legacy, with the version-bump warning for converting between them).

**Files:** `dev-docs/serialization.md`, `dev-docs/timers.md`, `dev-docs/claude-skills/modernuo-serialization.md`, `dev-docs/claude-skills/modernuo-timers.md`, `dev-docs/runuo-migration-docs/02-serialization.md`, `dev-docs/runuo-migration-docs/03-timers.md`, and a condensed v4 addition to CLAUDE.md rule 9.

**Example refresh:** the skill's `BagOfSending` "custom properties" example was itself converted in #2587 — it is now quoted in its real post-conversion form as the canonical hooks example; the real-examples list points at `BaseWeapon.cs` for custom getters and `BaseLight.cs` for the drifting-timer + `MigrateFrom` pattern.

Verified by grep: zero references to the removed v3 attribute names remain anywhere in `dev-docs/` or `CLAUDE.md`.
2026-08-22 18:29:27 -07:00
Kamron Batman
b042edcf0b
refactor: fold hand-written serializable property setters into field hooks (#2587)
## Summary

Folds **113** hand-written `[SerializableProperty]` members into plain `[SerializableField]` declarations using the v4 setter hooks — value coercion/vetoes via `allowFieldChange`, post-change side effects via `fieldChanged` (whose `oldValue` parameter covers the old-house/old-sender unsubscribe patterns). Net **-450 lines** of setter boilerplate.

```cs
// before
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int Charges
{
    get => _charges;
    set
    {
        _charges = Math.Clamp(value, 0, MaxCharges);
        InvalidateProperties();
        this.MarkDirty();
    }
}

// after
[SerializableField(1, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _charges;

private bool AllowChargesChange(ref int value)
{
    value = Math.Clamp(value, 0, MaxCharges);
    return true;
}
```

## How sites were selected

A classifier parsed all 204 `[SerializableProperty]` sites and converted only those matching strict shapes: getter is exactly `get => _field;`, the assignment comes first (after at most an equality guard), and relocated side effects contain no `return`, no `value` mutation, and no field re-assignment. Everything else was left alone deliberately:

- **~34 custom getters** (fallback defaults like `_x == -1 ? Default : _x`, self-healing refs) — no setter hook can express these.
- **~35 pre-assignment logic** (durability Unscale/Scale sandwiches, old-state captures like PotionKeg's pile weight).
- **virtual/override members, name-mismatched backing fields (`m_`), exotic semantics** (guards' `Focus` does work on *equal* assignment; `ChampionSpawn.Active` never assigns its field).

Five sites the classifier refused were converted by hand where the hooks fit cleanly: `ReceiverCrystal.Sender`, `PlayerVendor.House`, `PlayerBarkeeper.House` (old-value unsubscribe via `oldValue`), `BaseSuit.AccessLevel` (its existing virtual `OnAccessLevelChanged` already had the exact callback shape), and `DyeTub.DyedHue` (a true veto: `AllowDyedHueChange(ref int value) => _redyable`).

## Verification

- Build: **0 errors, 0 warnings**.
- **Schema regeneration produces zero Migrations changes** — the conversion is wire- and schema-neutral by construction (same orders, types, and property names), and CI's schema diff check enforces it.
- **835 + 708 tests green.**

## Behavioral notes (all strict improvements, called out for review)

- Generated setters skip everything when the incoming value equals the current one; a few converted setters previously re-ran side effects on equal assignment (redundant `Update()`-style refreshes).
- Generated setters always `MarkDirty()` on change; several converted setters never did (e.g. `DyeTub.DyedHue`, `MorphItem` ranges) — their changes only persisted if something else dirtied the entity. Those latent persistence bugs are fixed by construction.
2026-08-22 18:20:39 -07:00
Kamron Batman
73f9688083
feat: adopt serialization generator v4 (field-side linkage, anchored timers) (#2586)
## Summary

Adopts ModernUO.Serialization 4.0.0 across the engine. Three commits, reviewable independently:

1. **Package + tool bump to 4.0.0** (`Server.csproj`, `UOContent.csproj`, `dotnet-tools.json`).
2. **Timers → `[DeserializeTimer]`** — the 8 drifting timers (BaseLight, TreasureMapChest, MarkContainer, FillableContainer, DeathRobe, DecayedCorpse, Corpse, BaseEscortable) now store their next tick as **anchored time**: server downtime no longer consumes the remaining delay, and idle-world saves are byte-stable. This changes their wire format, so each class bumps its serialization version with a `MigrateFrom` that replays the old delta-time read through the migration schema (the new `vN.json` files carry `@AnchoredTimer`; the old ones keep `@TimerDrift`, which the generator reads forever). The 2 wall-clock timers (Aquarium, FountainOfLife) keep their exact format via `wallClock: true` — no bump. Restart methods drop their `TimeSpan.MinValue` sentinel checks: v4 invokes them **only when a timer was actually running at save**.
3. **Linkage → field-side declarations** — 175 conversions across 25 files: `[SerializableFieldSaveFlag(order)]`/`[SerializableFieldDefault(order)]` become `[SaveFlag(nameof(...), nameof(...))]` on the field, and `[SerializableFieldChanged(order)]` becomes the `fieldChanged:` argument of `[SerializableField]`. **Wire-neutral: zero migration schemas changed.**

## Verification

- Solution builds with **0 errors, 0 warnings**; all three 4.0.0 packages verified indexed on nuget.org (no local feed needed).
- **835 + 708 tests green.**
- Generated output inspected: old-version content structs replay `ReadDeltaTime` (e.g. `V3Content.DecayTimerNext = reader.ReadDeltaTime()`), current versions write/read anchored time with the gated restart, and the wall-clock classes emit byte-identical `Write`/`ReadDateTime` framing.
- Schema tool run is committed (CI's `git diff --exit-code` schema check passes): exactly the 8 expected new `vN.json` files, nothing else touched.
- The conversion was scripted with a class-scoped resolver (order → same-class `[SerializableField(order)]`/`[SerializableProperty(order)]`); it planned 175/175 with zero ambiguities before applying.

## Notes

- New `MigrateFrom`s use the content structs' provided `XxxDelay` property, matching the pre-existing idiom in Corpse's and TreasureMapChest's older migrations.
- Follow-up candidate (separate PR, wire-neutral, any time): fold the ~150 eligible hand-written `[SerializableProperty]` setters (clamps, post-change side effects) down to `[SerializableField]` with `allowFieldChange`/`fieldChanged` hooks.
2026-08-22 17:54:02 -07:00
Kamron Batman
126a10ce53
feat: anchored-time infrastructure with a save-start anchor in idx v5 (#2585)
## Summary

The save-stability infrastructure consumed by generator v3's `[AnchoredDateTime]`: anchored timestamps are written as **absolute values** and re-based once at load by the elapsed time since the save started — so downtime doesn't age them, and an unchanged entity serializes to identical bytes (the prerequisite for replacing delta-time encodings, which rewrite every entity on every save).

## Design

- **`WriteAnchoredTime` / `ReadAnchoredTime`** on `IGenericWriter`/`IGenericReader`. The read side applies the reader's `AnchoredTimeShift`; `Min/MaxValue` sentinels pass through unshifted, and shifts saturate instead of overflowing.
- **`World.SaveStartTime`** is stamped the moment the world freezes for a snapshot — one anchor for the entire save, no per-persistence skew.
- **idx v5**: the anchor ticks sit in the header right after the version. The anchor travels with the file it re-anchors, so a single idx+bin pair restored from a backup is self-describing, and anchor presence is guaranteed by the same version gate as the record format — there is no separate anchor file to lose.
- **The shift rides the reader instance** (`BufferReader`, `UnmanagedDataReader`, `BinaryFileReader` delegating), not a static — parallel per-persistence loads and ad-hoc restores each see their own file's anchor. idx v4 and older read with a zero shift.

## Scope

Behavior-neutral: nothing serializes anchored values yet (`Item.DecayResetTime` and the `[DeltaDateTime]` field migrations come separately, with their own version bumps). Saves written from this branch are idx v5; loading v4/v3 saves is unchanged and remains pinned by the existing hand-written-header tests.

## Testing

- Unit round-trips: exact with zero shift, shifted read, sentinel passthrough, saturation, Local→UTC normalization.
- End-to-end through the real worker/segment-log pipeline: an anchored timestamp re-bases across a simulated two-hour downtime via the idx v5 header.
- Full suites green: Server.Tests 835/835, UOContent.Tests 708/708 (including the existing v4/v3 idx loading tests).
2026-08-22 15:59:39 -07:00
Kamron Batman
541dbc5ac5
feat: Bumps dependencies. Introduces Serialization Generator v3 (#2584)
### Summary

* Upgrades Serialization Generator to v3. This contains numerous bug fixes and a significant performance improvement.
* Bumps other dependencies.
2026-08-22 15:48:53 -07:00
Kamron Batman
971d7b6a77
fix: stop items from insta-decaying when decay eligibility is restored without a move (#2583)
## Summary

A GM flipping `Movable` back on for a long-frozen item made it vanish within one scheduler tick. The setter registered the item with a deadline computed from its stale `LastMoved`, so `ProcessActiveQueue` deleted it almost immediately. The pre-#2311 save-time sweep had the same semantics, just hidden behind the save cadence. The same failure existed for `Visible` and `Spawner` transitions.

`LastMoved` is deliberately left meaning actual movement — it feeds vendor inventory expiry and house moving-crate checks — so the fix does not rewrite it for state changes.

## Changes

- **`DecayResetTime`** (CompactInfo-backed): the decay countdown runs from the later of `LastMoved` and this stamp. `RestartDecay()` stamps it only when the item can decay and the stamp extends the current deadline, so hot paths with a fresh `LastMoved` allocate nothing.
- **`Movable`/`Visible`/`Spawner` setters** call `RestartDecay()` instead of registering a stale deadline.
- **Region-refusal retry** in `DecayScheduler` uses `RestartDecay()` instead of rewriting `LastMoved`.
- **Persistence**: the stamp survives save/load as a `WriteDeltaTime` delta under `SaveFlag.DecayReset` (to become `WriteAnchoredTime` once the save-time anchor is ported) (Item serialization v10), so a restart mid-window no longer deletes the item.
- **`LastMoved` setter** drops a superseded stamp so the `CompactInfo` can collapse instead of being held (~40 bytes) forever.
- **Raw `Map` setter** now counts as a move for parentless items: it stamps `LastMoved` and updates decay registration, closing the gap where an item moved out of `Map.Internal` via the setter never decayed.
- **`LiftItemDupe`**: the remainder of a partially lifted *ground* stack was placed via raw `Location`/`Map` assignments and never enrolled for decay (lingering-trash leak since #2311) — now enrolled via the Map setter. Parented remainders get their map from `AddItem` (parent first, then map), so container splits never transit the scheduler.
2026-08-22 12:56:00 -07:00
Kamron Batman
fd27b7a3c9
chore: Simplify server requirements section in README (#2582)
Removed unnecessary details about game logic and server requirements.
2026-08-21 19:22:04 -07:00
Kamron Batman
be3a08513f
fix: Fixes PlayerConstructed stacking/BODs (#2579)
### Summary

* Removes player constructed as a requirement for BODs.
* When two items stack and they don't match player constructed flags, the resulting stack loses the flag.
2026-08-14 17:14:38 -07:00
Kamron Batman
2dbaa87377
feat: make the blocklist and manual allowlist opt-in; cut the ban subsystem's on-loop cost (#2577)
Two features ran on every shard out of the box, each polling on its own 60s timer for files most shards never generate, neither ever asked for. Fixing that turned into untangling why they shared a config file — and then into the on-loop cost of the three lists behind them.

## Before / after

Measured on the shipped defaults. On-loop numbers are what freezes the world; the tick budget is 8 ms.

| | before | after |
|---|---:|---:|
| Blocklist poll on a shard with no list | every 60s, forever | **none** (opt-in) |
| Manual allowlist poll on a shard with no carve-outs | every 60s, forever | **none** (opt-in) |
| Promote-guard sweep timer | leaked on `Stop()` | stopped, and only started when hits are reported |
| Login allowlist flush, on-loop | O(n) walk + 2 arrays **every 60s**, LOH past ~5,300 entries | reused buffers, **hourly**, zero steady-state allocation |
| Auto-denylist, accept path | 9.1 ns/call | **6.1 ns/call** |
| Auto-denylist, sustained flood at cap (60k rejected) | 26.7 ms | **9.3 ms** |
| Auto-denylist, flood end — **worst single call** | 9.49 ms | **0.05 ms** |
| Auto-denylist cap | 65,536 (stranding 9,895 slots) | **324,449** (exact `HashSet` capacity, ~19 MB) |

The auto-denylist row that matters is the third: the on-loop stall at flood end drops **190×**, because retiring lapsed holds is now the number expiring rather than the number held.

## Why this design

It is built for the shape of attack these shards actually see: **hundreds to a few thousand connections per second**, occasionally tens of thousands, sustained over minutes rather than delivered instantly. Against that shape the cap now covers the whole observed range (50k–250k distinct sources) in memory, and the work of expiring them spreads across the accept calls that were already happening.

There is one case this design is *worse* at than the old one: if every held entry lapses within the same millisecond, retiring them costs ~10.7 ms against the old ~8.9 ms, because the ring's random-access set removals lose to a sequential dictionary scan. Reaching it requires an entire flood to arrive inside one millisecond. **A shard absorbing 324,449 connections in a millisecond is finished at the accept path no matter what this list does** — that is the point where the answer is upstream security and scrubbing (an L4 proxy, edge filtering, a bouncer at the kernel), not a data structure in the game loop. We chose the design that fits the attacks we see and degrades honestly past them, rather than over-engineering for one we do not.

## Blocklist — now opt-in

`BlocklistFilter.Start` only bailed when `_path == null`, which needs `file` to be empty. The default is `"Configuration/ip-blocklist.txt"`, so on any default install both `Task.Run(PollLoop)` and a recurring `SweepGuard` timer started unconditionally, logging *"Blocklist inert: no list at …; polling every 60s"* and then doing exactly that forever.

Adds `"enabled"`, default `false`, using the `_enabled = s.Enabled && <preconditions>` idiom already in `LoginAllowlist` and `AutoDenylist`. **Upgrade is deliberately loud**: a missing key binds to the default, so `LogWhyDisabled()` splits three cases and a shard with a list on disk but no `enabled` key gets a **Warning**, not silence.

## `FileAllowlist` → `ManualAllowlist`, with its own config

Moves to `Configuration/ip-allowlist.json` (`enabled` default `false`, `files`, `reloadInterval`) and into `Network/ManualAllowlist/`, mirroring `Network/LoginAllowlist/`.

It was never a sub-feature of the blocklist. `ManualAllowlist.Contains` has two callers:

| Caller | Could anything else do it? |
|---|---|
| `BlocklistFilter.Evaluate` | **Yes** — the generator already subtracts these files at generation time |
| `BanExemptions.IsExempt` | **No** — sole mechanism for suppressing behavioural ban contributions |

The second reaches `BanChannel.IsExempt` with no blocklist in the path. A shard running **no blocklist** still needs this so the admin's own IP isn't auto-banned by rate-limit detection, so a shared flag couldn't express it — the implication is asymmetric. They still work together via a startup warning when the blocklist is on and the allowlist is not.

On the name: "File" described the storage. The distinction from `LoginAllowlist` is **provenance** — declared by an operator versus earned by authenticating — and "Manual" matches `BanReasons.Manual`. `allowlistFiles` is removed from `BlocklistSettings` outright; blocklists have not shipped long enough for anyone to have set it.

## Login allowlist flush

`Flush()` allocated two arrays sized to the live entry count and copied the whole dictionary into them **on the game loop**, every 60s. `UInt128` is 16 bytes, so past ~5,300 entries that first array was an LOH allocation once a minute, forever. The file write was already off-loop; the walk was not.

Static buffers grown geometrically; the writer owns them until it posts completion back through `Core.LoopContext`, so `_writing`/`_dirty` stay loop state (rule #10). Interval → 1 hour against a 90-day TTL. Clean shutdown writes synchronously via `EventSink.Shutdown`; `HandleClosed` skips `InvokeShutdown` when crashed, so the crash path subscribes separately and only writes when it is actually on the loop thread. Also fixes a pre-existing hole where `_dirty` was cleared *before* the write, so a failed write dropped entries despite the comment promising a retry.

## Auto-denylist: expiry ring

Reclaiming lapsed holds was O(entries held) — every cap-triggered reclaim during a flood walked the whole dictionary to find the few that expired, and `_warnedFull` suppressed the log, not the work.

A hold is **never refreshed** now: the first detection sets the expiry, later ones leave it. That makes insertion order equal to expiry order, so a ring of the same keys is sorted by construction and retiring stops at the first live record. Nothing is lost — the rate limiter runs *ahead* of the connection filters (`NetState.Network.cs`) and reports to the ban channel, so a flooder whose hold lapses is re-held on its next attempt.

Because the ring carries the expiry, the membership side only answers "present?", so it is a `HashSet` — measured at **36 B/slot against the dictionary's 52**. `HashSet` and `Dictionary` share `HashHelpers`, so the from-empty capacity progression is identical (36,353 → 75,431 → 156,437 → 324,449 → 672,827) and the cap still lands on one exactly. The ring is parallel `UInt128[]`/`long[]` rather than an array of structs — `UInt128` forces 16-byte alignment, so a packed pair costs 32 bytes where these cost 24, and the drain reads only the `long[]`.

Rejected after measuring: splitting the drain into a scan loop plus a removal loop (inside noise — both issue N hash removes, and the pointer math was never the bottleneck), and `Dictionary<UInt128,bool>` with tombstoning instead of removal (10% slower *and* unbounded, which breaks the cap).

## Testing

Build clean, 0 warnings. **1,530 tests pass** — 708 UOContent, 822 Server.

Tests were reworked rather than patched: the refresh test inverts to `Repeat_detection_does_not_extend_the_hold`, the obsolete sweep-throttle test is deleted along with the throttle, and four were added for the ring — set/ring parity, release-then-re-hold not being retired by the stale record, exact fill of a non-power-of-two cap, and the moved allowlist config's casing contract. The throttle test added mid-PR was verified to fail without its fix before being deleted.

One commit is comments only (verified: a diff filtered of `//` lines is empty), removing development narration — a `"(Task 2)"` plan reference, `"matching the per-feature JSON config pattern used by X"` across four loaders, a duplicated threading note — and repointing `Firewall` at `dev-docs/ip-bans-and-allowlists.md` instead of a "ban-channel design doc" that does not exist.

Note `Distribution/Configuration/blocklist.json` is gitignored (`.gitignore:14`) and generated from the record defaults on first boot, so the record default *is* the shipped default.
2026-08-13 23:22:35 -07:00
Kamron Batman
240118340e
fix: stop the idle-sleep backoff tripping on healthy hosts (#2572)
## Problem

The late-wake detector added in #2559 suspends idle sleeping on perfectly healthy hosts. The visible symptom is this Warning firing periodically on stable machines:

> This host returned a 2ms idle wait at least 8ms late 2 time(s) in the last second; idle sleeping suspended for 5000ms

Demoting it to Debug would hide the symptom but not the cost: every one of those lines means the shard dropped idle sleeping for 5s and burned a full core for no reason. The detector is what was mis-tuned.

## Cause 1 — lateness was a count, not a rate

An idle loop performs **~400–500 sleeps per second** (2ms each, bounded by the 8ms wheel tick). The trip condition was `late > 1` across two consecutive one-second samples — a **0.4% tail-outlier rate**. A co-tenant burst, a page fault, or another process changing the system timer resolution clears that bar on a healthy host.

A host that genuinely cannot schedule the process — throttled burstable vCPU — returns *most* of its waits late. Signal and noise were two orders of magnitude apart, and the check sat in the noise.

Now gated on the proportion, with the absolute count kept as a floor:

```csharp
if (late <= _lateWakeThreshold)             { _consecutiveBadSamples = 0; return; }  // floor
if (late * 100 < sleeps * _lateWakePercent) { _consecutiveBadSamples = 0; return; }  // rate
```

New `server.lateWakePercent` (default `10`). The floor is what keeps a window with only a handful of sleeps from tripping on a meaningless percentage; `server.lateWakeThreshold` keeps its existing meaning.

## Cause 2 — GC pauses were charged to the host

`dev-docs/debugging-event-loop.md` already documents that the GC collects preferentially **during idle sleeps** — that is the natural pause point it looks for. So the detector was systematically measuring the GC's chosen pause point and billing it to the host's scheduler. Not an occasional coincidence; a designed-in one.

```csharp
var collections = GC.CollectionCount(1);
NetState.WaitForCompletion(requested);
...
if (elapsed - requested >= Timer.TickRate && GC.CollectionCount(1) == collections)
```

Gen1 (which counts gen2 with it) rather than gen0 — gen0 pauses don't approach the 8ms `TickRate` bar anyway, and gating on them would discard useful samples. The second read short-circuits behind the overshoot test, so the common path costs **one** `GC.CollectionCount` per sleep: an internal counter read, single-digit nanoseconds, ~500/sec.

## Cause 3 — every backoff logged at Warning

Tiered to the escalation that already existed, since a single suspension is recoverable and not something an operator can act on:

| Backoff | Level |
|---|---|
| 1–2 | `Debug` |
| 3–5 | `Warning` (now includes the sleep count and "for the Nth time running") |
| ceiling | `Error`, unchanged |
| recovery | `Information` (new) |

Each backoff doubles the suspension, so every line is already a distinct escalation step — no further rate limiting needed.

## Drive-by

The `BackoffResetAfterCleanMs` reset only ran on the path to a *new* backoff, making it unreachable for a host that recovered for good — such a host never cleared its escalation or re-armed `_loggedBackoffCeiling`. It now runs on every health sample, which is also what makes the new recovery line reachable.

## Testing

Full solution builds clean, 0 warnings. No tests added: the state is private static in `Core` coupled to `_tickCount` with no injection point, and nothing covered it before — adding a seam purely to test it seemed worse than the gap. Happy to add one if reviewers disagree.
2026-08-13 19:58:03 -07:00
Kamron Batman
9b35b39d0d
fix: stop stack merges and splits from laundering PlayerConstructed (#2576)
## Why

`PlayerConstructed` is per-instance provenance, and #2574 put it on every crafted item — including potions, arrows and other stackables. Stack operations were written when no item carried provenance of any kind, so they treated two piles of the same graphic as interchangeable.

**Merging** keeps the receiving stack's value. Dropping bought potions onto a crafted stack made the whole pile count as crafted; the reverse order erased it. Which one happened was decided by drag direction alone.

**Splitting** rebuilds one half in `Mobile.LiftItemDupe`, which copies a fixed list of fields rather than going through `Dupe`/`CopyProperties`. `PlayerConstructed` was not on that list, so dragging part of a pile off stripped the new half. Worth calling out: `[IgnoreDupe]` does **not** govern this path — it only applies to `Dupe()`. Reasoning "the field isn't `[IgnoreDupe]`, so it copies" is wrong here.

## Changes

- `Item.CanStackWith` compares `PlayerConstructed`, so crafted and non-crafted never merge into one indistinguishable pile.
- `Mobile.LiftItemDupe` copies `PlayerConstructed` onto the remainder, so a split cannot produce halves that disagree about what they are.

Refusing to merge is the whole fix. A stack has nowhere to record provenance, so the only coherent behaviour is to keep the two piles apart rather than pick a winner.

## What this deliberately does not do

Paths that genuinely **virtualize** an item — pouring from a `PotionKeg`, for one — rebuild it without the flag, and the result is simply treated as not crafted. That is accepted rather than worked around; the alternative is threading provenance through every count-based container, which buys little. The keg stores a `Held` int rather than a stack, so nothing there depends on merging and nothing breaks.

`CommodityDeed` is unaffected — it holds the real `Commodity` item rather than a count, so the flag rides along.

## Player-visible effect

Crafted potions and arrows will no longer stack with bought or looted ones. That is the intended invariant, and it is the reason the flag can be trusted at all.

## Tests

7 new tests in `Server.Tests`: both merge directions, the matching-provenance case, split copying, and the split/re-merge round trip.

`Server.Tests` **822 passing**, `UOContent.Tests` **701 passing**, build clean with 0 warnings.
2026-08-13 19:18:06 -07:00
Kamron Batman
55ac2c3d98
refactor: Move legacy deserialization into the .Migrations.cs partials (#2575)
Follow-up to #2574, which added a `.Migrations.cs` partial to `BaseWeapon`. Pure relocation — no behaviour change.

## The inconsistency

`BaseArmor` and `BaseClothing` already kept their pre-codegen `Deserialize(reader, version)` in a `.Migrations.cs` partial, but left the `OldSaveFlag` enum and the `GetSaveFlag` helper behind in the main class file — even though every call site is in the partial:

| Class | `Deserialize` | `GetSaveFlag` / `OldSaveFlag` | Call sites outside the partial |
|---|---|---|---|
| `BaseArmor` | already in partial | in main file | 0 of 26 |
| `BaseClothing` | already in partial | in main file | 0 of 12 |
| `BaseWeapon` | in main file | in main file | — |

`BaseWeapon` had all three still inline, with its new `.Migrations.cs` holding only a `MigrateFrom`.

## After

All three follow the same layout: `MigrateFrom` newest to oldest, then the pre-codegen `Deserialize`, then `GetSaveFlag`, then `OldSaveFlag`. That moves ~290 lines of legacy read path out of `BaseWeapon.cs` — the file that needed it most at ~3,900 lines — and leaves the main class files describing only how the type behaves today.

## Reviewing this

The diff is large and almost entirely noise, so it is probably not worth reading line by line. Two checks are stronger:

- **Nothing was lost or altered.** Across each `.cs` / `.Migrations.cs` pair, the multiset of non-blank source lines is identical to `main` except for one added comment (below). The relocation was done mechanically and asserted against that invariant rather than by hand.
- **Nothing about serialization moved with the code.** Running `ModernUOSchemaGenerator` after the move emits no new migration files.

The complete set of intentional additions:

- `using System;` in each of the three partials, for the `[Flags]` attribute (implicit usings are not enabled here).
- `// Version 9 (pre-codegen)` above `BaseWeapon`'s moved `Deserialize`, matching the marker `BaseArmor` and `BaseClothing` already carry. Version 9 is correct because `BaseWeapon.v10.json` is its earliest migration schema, so codegen began at 10.

Everything else is blank-line placement.

## Verification

Full solution builds in Release with 0 errors and 0 warnings; 1516 tests pass (815 `Server.Tests`, 701 `UOContent.Tests`).
2026-08-13 18:43:53 -07:00
Kamron Batman
bd79cb7759
fix: Consolidate PlayerConstructed onto Item, stamped by the craft system (#2574)
Follow-up to #2573. That change made `SmallBOD.EndCombine` require a player-crafted item, but it could only read provenance off `BaseArmor`, `BaseWeapon` and `BaseClothing`, because those are the only three classes that track it — hence the hand-enumerated `armor?.PlayerConstructed ?? clothing?.PlayerConstructed ?? weapon?.PlayerConstructed ?? false`.

The gap is structural rather than cosmetic. `PlayerConstructed` is set inside each base's `OnCraft`, so it can only ever reach types implementing `ICraftable`. Most craftables do not — the tinkering catalogue alone is largely plain `Item` subclasses — so any rule keyed on "was this actually crafted" has nothing to key on for those types.

## What changed

Provenance moves to `Item` and is stamped centrally in `CraftItem`, immediately after the item is constructed and before the `ICraftable` dispatch, covering both the AOS and T2A craft paths. The three `OnCraft` overrides drop their now-redundant assignment and inherit `Item`'s property, so no call site outside them changes — `Resmelt` and `SalvageBag` still read `armor.PlayerConstructed` and still compile unchanged. `SmallBOD`'s three-way null-coalescing chain collapses to `item.PlayerConstructed`.

`OnCraft` is only ever invoked from `CraftItem` (the other three call sites are `base.OnCraft` chaining), so removing those assignments has no other reachable effect.

## Storage cost: none

`Item`'s `SaveFlag` word is written as a fixed-width `int`, not an encoded one, so occupying bit `0x08000000` changes no record lengths. Items that are not player-constructed serialize byte for byte as before, and crafted ones differ by a single bit in a field already being written.

`Item` itself needs no version bump: a bare `SaveFlag` bit is self-describing, so records written before it existed lack it and read `false`.

## Version bumps

The three content classes do need one, since removing a serialized field changes their layout:

| Class | Version | Field removed |
|---|---|---|
| `BaseArmor` | 9 → 10 | 24 (was last, nothing renumbered) |
| `BaseClothing` | 7 → 8 | 7 (fields 8–10 shift down) |
| `BaseWeapon` | 10 → 11 | 26 (fields 27–30 shift down) |

Each gets a `MigrateFrom` for its previous version that assigns the old bool to the inherited property, so existing crafted armour, weapons and clothing keep their provenance across the upgrade. `Item.Deserialize` runs first and reads the absent bit as `false`, then the migration overwrites it — the generated `Deserialize` calls `base.Deserialize` before dispatching, so the ordering holds. `BaseWeapon` had no migrations file and gains one.

The renumbering is not stylistic: the generator requires contiguous field ordering and rejects a hole with `SG3005: Expected field 'Crafter' with order 7 but found 8`.

New schema JSONs (`BaseArmor.v10`, `BaseClothing.v8`, `BaseWeapon.v11`) are generated by `ModernUOSchemaGenerator` and committed alongside.

## One thing worth a second opinion

The new property is a plain auto-property on `Item`, so it does not call `this.MarkDirty()` the way the codegen setters it replaces did. `MarkDirty` is currently a no-op (`// TODO: Add dirty tracking back`) and no property in `Item.cs` calls it, so this matches the file as it stands — but it is worth noting if dirty tracking comes back.

## Verification

Full solution builds in Release with 0 errors and 0 warnings; 1516 tests pass (815 `Server.Tests`, 701 `UOContent.Tests`).
2026-08-13 18:34:59 -07:00
Kamron Batman
5ce0f1e92b
fix: Require BOD combine items to be player-crafted (#2573)
## Problem

`SmallBOD.EndCombine` validates an item's **type**, **material** and **exceptional quality**, but never checks that the item was actually crafted by a player. Any item matching the request is accepted, including one bought straight from an NPC vendor.

https://github.com/modernuo/ModernUO/blob/main/Projects/UOContent/Engines/Bulk%20Orders/SmallBOD.cs#L117-L168

Where a vendor stocks a type a BOD can request, a player can fill the deed by buying the items instead of crafting them, and pocket the reward gold for the difference.

Tailoring is the clearest case. `SmallTailorBOD.CreateRandomFor` guarantees `Material = None` and `RequireExceptional = false` below 70.1 skill, so the rolled deed asks for plain cloth items — and tailor vendors stock several of those directly. A qty-20 Bandana BOD can be filled entirely from vendor stock for a small fraction of the reward gold, with no crafting and no material cost.

The same shape applies anywhere else a vendor-sold type overlaps a requestable BOD type; tailoring is simply where the low-skill deed generator and the vendor inventory overlap most.

## Fix

Add a `PlayerConstructed` check alongside the existing material and quality checks.

```csharp
var playerConstructed = armor?.PlayerConstructed ?? clothing?.PlayerConstructed ??
    weapon?.PlayerConstructed ?? false;

if (!playerConstructed)
{
    from.SendLocalizedMessage(1045169); // The item is not in the request.
}
```

This follows the pattern already used in `Engines/Craft/Core/Resmelt.cs` (L98-L100, L155-L160) to distinguish crafted from store-bought items, and reuses the same null-coalescing chain style as the adjacent `GetMaterial(armor?.Resource ?? clothing?.Resource ?? CraftResource.None)` line directly above it.

`PlayerConstructed` is already set in `OnCraft` and serialized on all three bases (`BaseArmor`, `BaseWeapon`, `BaseClothing`), so the flag survives restarts and no serialization change is needed.

## Open question — the message

There is no dedicated cliloc for "this item must be crafted", so I reused **1045169** (*"The item is not in the request."*). It is arguably accurate — a vendor-bought item genuinely is not what the deed asked for — but it is not precise, and a player who does not know the rule will find it confusing.

I would rather flag this than invent a string. If there is a better cliloc, I am happy to switch it.

## Testing

`dotnet build Projects/UOContent/UOContent.csproj` — **0 errors, 0 warnings**.

Not covered: I have not added an automated test, as I could not find existing coverage for `EndCombine` to extend. Happy to add one if you would like it, with a pointer to the preferred pattern.

## Compatibility note

Any *already-existing* vendor-bought item in a player's possession will now be rejected by a BOD. That is the intended behaviour, but it is a visible change for anyone mid-deed. Worth a line in release notes.
2026-08-12 20:12:54 -07:00
Kamron Batman
1bc83339bb
fix: Fixes guardian lazy check on Treasure Map Chests (#2569)
### Summary

Fixes a crash bug from the lazy check on treasure map chest guardians.
2026-08-10 09:06:43 -07:00
Kamron Batman
c1442aff3e
fix: Stop treasure chest guardian spawn farming via stack splits (#2568)
### Summary

Players reported an exploit: decipher a treasure map, then run a ClassicUO/Razor organizer agent that pulls the gold out of the chest in small amounts. Each pull spawned more monsters, turning one chest into an unbounded farmable spawn generator.

### Root cause

`TreasureMapChest.OnItemLifted` grants a 10% guardian spawn roll per first-time-lifted item, deduplicated by the instance-keyed `_lifted` set. But a partial lift goes through `Mobile.LiftItemDupe`, which re-adds the stack remainder to the chest as a **brand-new item instance** (engine-side `AddItem`, bypassing the `CheckHold` block on refilling). Every subsequent pull lifts an instance the `_lifted` set has never seen, so each one re-rolls the 10% spawn chance:

- A level 4 chest holds 4,000 gold → pulled coin by coin, ~400 spawned creatures (plus more from reagent stacks), hands-free, per chest.
- Spawns use `guardian: false`, so nothing tracks or caps them.
- Legit full-stack looting yields roughly 5–8 bonus spawns per chest for comparison.

The code is inherited from RunUO, so descendant shards likely share the hole.

### Fix

Mark every item that enters the chest **after the initial fill** as already lifted, via an `OnItemAdded` override gated by a non-serialized `_filled` flag (set at the end of the constructor and in `[AfterDeserialization]`). Ordering makes this exact: `LiftItemDupe` re-adds the remainder *before* the chest's `OnItemLifted` runs, so the lifted original still gets its one legitimate roll while the remainder is pre-marked.

This also covers packing items *into* the chest (e.g., merging gold back in to lift it out again) and bounce-backs — anything not part of the original loot can never grant a spawn roll.

### Tests

- `PartialLift_MarksSplitRemainderAsLifted` — drives the real `Mobile.Lift` path with a 1-coin pull and asserts the split remainder is marked (failed before the fix).
- `ItemAddedAfterFill_IsMarkedLifted` — post-fill additions are marked (failed before the fix).
- `OriginalFillLoot_IsNotMarkedLifted` — original loot keeps spawn-roll eligibility.

Full `UOContent.Tests` suite: 701 passed.
2026-08-10 09:01:29 -07:00
Kamron Batman
0628902644
fix: harden idle-sleep scheduling against bad config and misattributed saves (#2567)
Follow-ups to #2559, from a review of the ported idle-sleep/scheduler-health changes.

### Fixes

- **`NetState.IsIdle` omitted `_pendingDisconnects`** — `Slice()` drains five queues; the property checked four. The other deferred work (`_connectingQueue`, alive checks, movement throttle) is time-gated and correctly excluded; the disconnect queue was the only ready-work omission. Impact was bounded (≤ one idle wait of delay), but the property's contract is "sleeping cannot strand pending work".
- **Neither new setting was clamped** (`Main.cs`):
  - `server.lateWakeThreshold: -1` made `late <= threshold` false for every sample even at zero late wakes, so from the second sample on, sleeping was re-suspended every second, forever — a permanent full-core spin whose only trace was a nonsense warning ("… at least 8ms late 0 time(s)").
  - `server.eventLoopIdleWaitMs: -1` disabled sleeping while the admin gump reported **Healthy** (it tested `== 0`).
  - Both now clamp to `>= 0` and log a warning naming the configured value. `-1` is a natural thing to reach for given the sibling key's doc says "set very high to disable".
- **World snapshots were misattributed to `StolenMs`** — `World.Snapshot` ran outside all five profiler phases, so a 3-second save inside a sample read as ~75% stolen, and `debugging-event-loop.md` teaches stolen = "the host ran something else". The diagnostic pointed operators at buying dedicated CPU for their own largest loop-thread stall. Saves now land in a new `WorldSnapshot` phase; `[LoopStats` iterates `PhaseCount` generically, so the report and CSV pick it up with no changes.
- **Admin gump conflated host-forced spin with configured spin** — when the startup probe finds no high-resolution wait support it zeroes the idle wait, after which the gump said "Spinning (configured)" and the operator's config said 2. New `Core.IdleSleepUnsupported` property; the gump now shows "Spinning - host cannot honor short waits" as a distinct fourth verdict. A genuinely configured 0 still reads "configured" (the probe only runs when the configured value was > 0).
- **The backoff-ceiling `Error` logged once per process lifetime** — `_loggedBackoffCeiling` never reset, and at the ceiling the method returns before the `Warning`, so a host that recovered (>60s clean streak) and later degraded back to the ceiling never re-logged the one operator-actionable message. The flag now resets with the clean-streak escalation reset.
- **Removed the unreachable "already suspended, extend" branch** — no sleeps occur while suspended, so `_lateWakes` stays 0 and every suspended sample early-returns before reaching it; with the threshold clamped it can never fire. If sleep gating ever changes, the normal path handles the case by counting a fresh episode.

`dev-docs/debugging-event-loop.md` updated to match (phase list + gump verdict table).

### Verification

- `dotnet build` clean (0 warnings) both normally and with `-p:EventLoopProfiling=true` (the snapshot phase only becomes live IL under the profiling flag).
2026-08-09 22:05:18 -07:00
Kamron Batman
6d846b11e5
perf: Sleep the event loop when idle. Fixes networking micro-stalls. Adds event loop instrumentation. (#2559)
## Problem

`RunEventLoop` span through its body regardless of whether there was anything to do — ~10% of a desktop core for an empty shard, and ~70% of a core on a 3 vCPU VPS. A process that never idles is exactly what burstable vCPU plans throttle, which is how this surfaced: lag spikes that went away when the operator bought more cores. The spin also denied the GC its natural pause points, so memory climbed until a world save forced a collection — alarming in task manager, harmless in practice, and a recurring source of "is my server leaking?" reports.

## Result

Windows desktop, real world of **190,728 items / 33,158 mobiles**, no players, saves and prebake off, three consecutive runs:

| | Legacy spin | Idle sleeping |
|---|---|---|
| **CPU** | 10.42 – 10.50% of one core | **0.78 – 1.00%** |
| **Tick lag** (peak/15s) | 4–10 ms | 5–11 ms |

**~10× less CPU with tick lag unchanged** — the CPU came free rather than being traded for latency. Slower hosts gain proportionally more. Spin mode (`server.eventLoopIdleWaitMs=0`) independently gained **7× the iterations per core** (1.19M → 8.3M cycles/sec) from the ring's AcceptEx rework.

## How

The loop blocks in `NetState.WaitForCompletion` whenever every queue it drains is empty (all the drains are bounded, so leftovers keep it awake). Receive completions, new connections, and cross-thread `LoopContext.Post` (via the ring's sticky `Wake()`) are all in the wait set, so sleeping adds no latency to any of them. Only timer-driven logic sees wheel lag, bounded by the idle wait.

**Health is measured at the only place sleeping can cause harm.** A sleep is bounded by the time to the next wheel turn, so a correctly honoured sleep can never miss a deadline — the only failure mode is the host returning the wait late. That overshoot is measured on every sleep (one extra timestamp read; production's entire accounting cost), and an escalating backoff suspends sleeping when it persists. By construction, server work — saves, heavy staff commands, deep timer callbacks — cannot trip it, so the warning means exactly one thing: *the host is not scheduling the process promptly*, with two known remedies (dedicated CPU, or `=0`). Hosts with no high-resolution wait mechanism at all are detected once at startup and spin instead.

**CPS is removed.** `Core.CyclesPerSecond`/`AverageCPS` measured nothing actionable before and became actively misleading once the loop sleeps (the rate is set by the sleep, not by shard health). The admin gump's Performance page now shows the verdict instead: `Healthy` / `Sleep suspended (host)` / `Spinning (configured)`.

## Configuration

| Setting | Default | Meaning |
|---|---|---|
| `server.eventLoopIdleWaitMs` | `2` | Longest idle block. Measured across 1/2/4/8 ms, 2 is where the trade stops being free. `0` = never sleep: ~98% of a core, zero scheduling overhead — for large shards on dedicated CPU. |
| `server.lateWakeThreshold` | `1` | Idle waits the host may return a full tick late, per second, before sleeping backs off. Raise for jittery hosts; very high disables the backoff. |

## Diagnostics (compiled out by default)

`dotnet build -p:EventLoopProfiling=true` compiles in `EventLoopProfiler` — every hook is `[Conditional("EVENT_LOOP_PROFILING")]`, so normal builds contain zero profiling IL. The profiling build decomposes each second of wall time into **work (per loop phase) / sleep / GC pause / stolen residual**, keeps ~15 minutes of history in a ring buffer, and the `[LoopStats` command prints the last minute and dumps the full history to CSV. `dev-docs/debugging-event-loop.md` is the diagnosis guide (for humans and AI): what production already tells you, when to flip the profiling build, the signature table for host-steal vs deep-processing vs GC vs wake bugs, why dotnet-trace comes last, and the GC/RAM "leak" misconception.

## Verification

- 815 Server.Tests green; both build configurations compile.
- Docker echo harness green on epoll and io_uring (ping-pong mode); kqueue verified manually on an M1 Max.
- A/B measurements and per-change numbers: `measure/event-loop` branch.

## Notes

The full measurement harness and vendored ring sources used to develop this live on the [`measure/event-loop`](https://github.com/modernuo/ModernUO/tree/measure/event-loop) branch, kept for future loop work.
2026-08-09 13:24:59 -07:00
Kamron Batman
a7e65aab01
perf(login): run password hashing on a parked worker thread (#2566)
## Why

An Argon2 verify is **~8.9 ms of frozen world per login attempt** — more than half a 16 ms frame. Failed attempts cost exactly the same as successful ones, by design, so a credential-stuffing flood is a full-cost stall per packet without needing valid credentials. `SetPassword` derives a hash too, so `[password`, the admin gump and account creation each pay the same.

## What the measurement says

Off-loading does not delete the cost, it relocates it. Three things stay on the loop:

| Component | Measured |
|---|---:|
| Inline verify (today) | **8.92 ms** |
| Dispatch to the worker | 210 ns |
| Drain the continuation off `LoopContext` | 13 ns |
| Loop's own work slowed by shared-L3 eviction | **0.05 – 5.44 ms** |

Net gain **3.5 – 8.9 ms** of on-loop time per login. Harness in `ModernUO-Benchmarks` (`Benchmarks/Argon2OffLoop/`): it models the loop as a dependent-load pointer chase swept across working-set sizes, which is an upper bound on cache-latency sensitivity, and copies `EventLoopContext` so the hand-off cost is the real one.

Two results shaped the design:

- **The contention tax peaks in the middle of the working-set range**, not at the top — 5.44 ms at 8 MiB (a quarter of this chip's L3), but 0.76 ms at 30 MiB and 0.10 ms at 256 KiB. A tiny hot set has nothing in L3 to lose; a huge one is already DRAM-bound.
- **Per-login tax falls as concurrency rises** (5.44 → 2.56 → 1.60 ms at 1/2/4 hashers) while *total* loop damage rises. Contention is shared, not additive, so a login rush is not the disaster case — a single login is.

## Why exactly one worker

It is load-bearing three times over, which is also why it must not quietly become a pool:

- **Cost bound.** Off-loop loses to inline only if a hash steals ~82% of the loop's throughput. One hasher contending for one core leaves the loop ~50%. **A single background hasher cannot cost the loop more than the inline verify under any scheduling regime**, which is what lets the measurement hold on hardware we cannot inspect — AMD, VPS, oversubscribed VM. Four hashers drop the loop to ~20% and break it.
- **Memory.** Exactly one hashing arena is live at a time whatever the login volume.
- **Ordering.** Writes apply in dispatch order *only* because a single thread drains FIFO. A second worker would need ordering reintroduced; `WritesApplyInDispatchOrder` fails if that happens.

Throughput is ~110 verifies/sec. Only loop time matters, not login latency, so head-of-line blocking during a rush costs nothing.

## Making every protection safe off-thread

The worker was initially Argon2-only. That was the right call for the wrong reason — it was blamed on Argon2's salt RNG, which is a stateless syscall wrapper and was never a problem. The real blockers were elsewhere, and both are fixed at the source:

| Protection | Was | Now |
|---|---|---|
| MD5/SHA1/SHA2 | shared `HashAlgorithm.ComputeHash`, which carries the running digest across `HashCore`/`HashFinal` through process-wide singletons | static `HashData` into a `stackalloc` span — no state, no allocation, identical bytes |
| PBKDF2 | `Utility.RandomMinMax` → shared `System.Random`, thread-unsafe *and* game state | `RandomNumberGenerator.GetInt32`, matching the salt beside it |
| Argon2 | already safe (`Verify` is static + stackalloc) | unchanged, singleton reused |

Literal digests are pinned in a test **before** the change and still pass after it. These are compared as strings against every account database, so any casing or encoding drift would lock out every SHA and MD5 account at once.

With all three safe, the worker no longer knows which algorithm it runs and the dispatch conditions collapse to "is off-loop available".

## Correctness

- **Phrase derivation** moves to `AccountSecurity.DerivePhrase`, so verification (stored algorithm's rule) and rehash (target algorithm's rule) cannot disagree. Deriving with the wrong one is the shape of the lockout fixed in #2562.
- **Liveness** is checked at dequeue *and* at apply — a connection can drop while queued or while the result sits in the loop queue. A job with no connection attached, such as an admin password change, runs regardless.
- **Queue overflow rejects** a login rather than verifying inline; steering work back onto the loop is what a flood wants. A password change instead falls back to hashing inline, because unlike a login it must not be dropped.
- **Shutdown and crash** both just stop the thread, and pending jobs are dropped. No save is initiated once shutdown begins — saving is the operator's choice up front, via the admin gump's save/no-save variants, and `WaitForWriteCompletion` honours one already in flight — so a write applied during teardown would reach no disk. The crash path needs its own subscription because `HandleClosed` skips `InvokeShutdown` when crashed.

## Bounding

`MaxPending` is 4096 — a backstop, not a flood defense. `SentFirstPacket` holds a connection to one pending verify and the engine caps connections at 4096, so the queue is already bounded by construction and this can only trip if that invariant breaks. A cap low enough to blunt an attack would reject real players first; during a mass reconnect they *are* the queue. Flood defense belongs at the connection layer.

The real DoS improvement is elsewhere: today every attempt stalls the world, and after this a flood occupies one core while the loop keeps ticking.

## Gate

Release builds on 4+ cores. Below that there is no spare core to move work to, so off-loading buys nothing by construction; `DEBUG` is excluded because dev boxes and test shards have few logins. Both modes call the same code — the gate only chooses where it runs.

## Engine change

One property, `AccountLoginEventArgs.Deferred`, so a subscriber can say "no verdict yet". `EventSink.AccountLogin` is `Action<...>` with no continuation, and the packet handler replies in the same call. Approved separately since it touches `Projects/Server/`.

## Docs

`dev-docs/threading-model.md` and the threading skill gain a vetted-workers section. The forbidden-patterns table bans `new Thread`, `ConcurrentQueue<T>`, `Interlocked` and `volatile` in `UOContent`, and its exceptions covered only `Projects/Server/` — the existing Advanced Search fan-out already sat outside it. The new section leads with proving the need (measure on-loop time, not wall-clock; gate on core count; record the measurement), keeps game logic on the loop via chunking, and documents the hand-off protocol in both directions.

## Testing

698 UOContent tests, 810 Server tests, Release build clean.

Covered: verify and rehash outcomes, phrase rules for SHA1/SHA2 vs Argon2, stored-format stability for MD5/SHA1/SHA2, jobs with no connection attached, and dispatch ordering through the real queue. The liveness and ordering guards are mutation-verified.
2026-08-09 00:13:34 -07:00
Kamron Batman
cce035f1c3
fix: Removes unnecessary dictionary removal guards (#2565)
## What

`Dictionary<K,V>.Remove` and `HashSet<T>.Remove` do not bump the collection's version, so removing an entry during a `foreach` does not invalidate the enumerator. A number of loops were still paying for a `PooledRefQueue`/`PooledRefList` to collect keys and drain them in a second pass. This drops those guards.

## Why it's safe

Verified against .NET 10.0.10 rather than taken on trust, since the documented guarantee covers only `Dictionary<TKey,TValue>.Remove` while several of these call sites are `HashSet<T>` or enumerate `.Keys`/`.Values`:

| Case | Result |
|---|---|
| `Dictionary` foreach + `Remove` | safe, all entries visited |
| `Dictionary.Keys` / `.Values` foreach + `Remove` | safe, all entries visited |
| `HashSet` foreach + `Remove` | safe, all entries visited |
| `Dictionary` foreach + `Remove` **then `Add`** | throws `InvalidOperationException` |

Reflection on `_version` confirms the mechanism: neither `Dictionary.Remove` nor `HashSet.Remove` touches it. Because `Remove` never bumps the version, the `Keys` and `Values` enumerators are just as safe as the dictionary's own, even though only `Dictionary.Remove` documents the behaviour. No entries were skipped in any case.

The `HashSet` half is confirmed by [stephentoub on dotnet/dotnet-api-docs#8177](https://github.com/dotnet/dotnet-api-docs/issues/8177#issuecomment-1167251052): *"Both HashSet and Dictionary have been improved to support removal during enumeration. The docs may just benefit from updating."* The gap is in the documentation, not the runtime.

`Remove` followed by `Add` in the same enumeration still throws. That is the line this PR does not cross.

## Guards removed

`VisibilityList`, `ChampionTitleSystem`, `Channel`, `BombingRun`, `Ruleset`, `PuzzleChest`, `RaceChangeGump`, `StepCache`, `PlayerMurderSystem`, `VirtueSystem`, `ProjectedItem`, `StaminaSystem`, `AIGroupMovement`, `PromotedGuard`, `AutoDenylist`, `LoginAllowlist`, `AntiMacroSystem`, `DetectHidden`.

Both collection kinds are covered: `Dictionary` (including loops over `.Keys` and `.Values`) and `HashSet` (`ProjectedItem._active`, `PlayerMurderSystem._contextTerms`, `StaminaSystem._resetHash`). In `StaminaSystem.ResetTimer` the `Count == queue.Count → Clear()` branch goes away with the queue — it only existed to avoid paying for N individual removes.

Where the collection supports it, `Contains` + `Remove` and `TryGetValue` + `Remove` also collapse into a single lookup (`if (list.Remove(x))`, `if (m_Pending.Remove(ns, out var state))`).

`Utility.Tidy<K,V>` keeps its two branches: when `K` is serializable the value is not inspected, otherwise the value is. Only the serializable side may be cast, so `Dictionary<Mobile, int>` and `Dictionary<Mobile, string>` stay valid.

## Deliberately unchanged

**`BaseCreature.LoyaltyTimer.OnTick`** keeps its deferred-delete queue. Removing from `World.Mobiles` while enumerating it is safe, but `Mobile.Delete()` is not a `Remove` — it runs `OnDelete`/`OnAfterDelete`, the `OnParentDeleted` cascade over the creature's pack, `DropHolding()`, and region and guild callbacks. Anything in that surface that constructs a `Mobile` is an `Add` into the dictionary being enumerated, which does invalidate it. `BaseHire.PayTimer.OnTick` has the same shape and is likewise untouched.

**Spatial-query buffers** — `GuardedRegion.CallGuards`, `Thunderstorm`, `Exorcism`, `LeverPuzzleController`, `BaseCreature.TeleportPets` — are a different hazard. They buffer the result of a range query because the drain moves or harms mobiles, which mutates sectors mid-enumeration.

**Re-entrant drains.** The `_users` sets in `Firebomb` and the explosion, conflagration and confusion-blast potions look like this pattern but are not: the loop collects, `Clear()`s, and only then runs `Target.Cancel` on each, which can re-enter. `AnimalTrainer` enumerates `pm.Stabled` and drains through `RemoveStabled`, which nulls the `Stabled` field once it empties — safe for an in-flight enumerator, which holds the set reference rather than the field, but subtle enough not to be worth inlining on a cold path.

## Verification

`dotnet build` clean with 0 warnings; 810 Server and 684 UOContent tests pass.
2026-08-08 11:50:01 -07:00
Kamron Batman
f33bcd6006
fix: Bind the login auth id to its account and drop the redundant verify (#2564)
## What

- Bind the login auth id to the account **and** origin address that earned it, make it a CSPRNG draw, expire it after two minutes, and spend it only once its owner presents it.
- Skip the password verify on `GameLogin` (0x91) when the presented id vouches for the submitted username and address.

## Why

A full client login hashes the password twice — `AccountLogin` (0x80) and then `GameLogin` (0x91). At the current Argon2 parameters that is **most of a 16 ms frame each, on the single-threaded game loop**, for every login attempt.

The second verify is redundant. `GameLogin` already requires an id from `_authIDWindow`, and that window is only populated by `GenerateAuthID`, called from `PlayServer` — reachable only after 0x80 has already authenticated the account **in this same process**. ModernUO Gateway has its own auth-id passing mechanism and is out of scope here.

## Why the id needed hardening first

Skipping the verify promotes the id from a correlation token to a bearer token, and it was not one:

- drawn from `Utility.Random` → `BuiltInRng`, a non-cryptographic PRNG
- bound to nothing — `AuthIDPersistence` carried only `Age` and `Version`
- never expiring; `Age` was only read to pick an eviction victim

A guessed id got you nothing while the password was still checked. Without that check it would have been an account takeover, so the id is now a CSPRNG draw, single-use, two-minute TTL, and bound to both the account and the origin address.

What remains is observing a live id on the client's network or machine — which the server cannot defend against under any design, and which already yields the password itself, since the client transmits it in the same handshake.

Network switching mid-login is deliberately unsupported.

## Behaviour

A full verify was always required before this change, and ids never expired, so every "before" is a password check.

| Case | Before | After |
|---|---|---|
| Id absent | Disconnect | Disconnect |
| Address mismatch | Verify | **Disconnect** |
| Account mismatch | Verify | **Disconnect** |
| Expired | Verify | **Verify** |
| Id vouches | Verify | **Skip** |

No case grants access the previous code would have denied. Expiry deliberately falls back to the verify rather than disconnecting — a player can idle, and turning that into a lockout would be a regression for no gain.

## Look, then take

An id is not consumed until the presenter has shown it is theirs. Removing it first would let anyone who lands on a live id burn it, and its owner would arrive to `"Unable to find auth id."` and have to log in again over a packet they had no part in.

The **address is compared before the account**, so a guesser from anywhere else is rejected before a username is ever looked at. That is what makes it safe to leave the id in place on a mismatch: there is no username-enumeration risk to trade against, and the only presenter who could enumerate is already on the victim's own address.

## The window is not a cap

It was 128 entries with the oldest evicted to make room. That is a cap on *concurrent logins*, not a resource bound: 800 people picking a server at once would have live ids discarded and those clients would arrive to `"Unable to find auth id."` — a failed login caused by nothing except other people logging in.

Issuing now sweeps expired entries and lets the window grow if everything in it is still live. Unbounded is safe here: an entry costs a **successful** password verify to create and dies after two minutes, so its size tracks logins genuinely in flight.

Removing an id when its connection drops is not an option, and this was checked rather than assumed — `NetState.cs:787` disconnects the login connection *deliberately*, immediately after the id is issued, and that disconnect is never cancelled. Surviving it is the whole purpose of the id. Expiry is the only correct reclamation.

## Handshake hardening

Choosing a server queues a disconnect, but the queue drains on the *next* slice, so a client pipelining into the same recv buffer can reach the handshake handlers again. Two had no do-once guard:

- `LoginServerSeed` (0xEF) now rejects when `state.Seeded` is already set.
- `PlayServer` (0xA0) now rejects when `state.AuthId != 0` — otherwise a connection that had already spent its id would be handed the spent one back.

Issuing is also idempotent (`EnsureAuthId`), so a connection holds exactly one id by construction and an orphan is impossible rather than something to clean up. The login state machine itself is untouched.

Also fixes a fall-through: the "Unable to find auth id" branch disconnected without returning, then continued with a default entry and nulled `state.Version`.

## Testing

`ConsumeAuthId` is a seam with no `NetState` dependency, so the auth decision is tested directly: vouching, account mismatch, address mismatch, case-insensitive usernames, IPv4-mapped-IPv6, unknown ids, single-use by the owner, **a rejected attempt leaving the id redeemable**, expiry-into-verify, and an 800-id login rush that must evict nobody. Expiry is driven by moving `Core._now`, not by waiting. Every new clause was verified to discriminate by removing it and confirming only its own tests fail.

## Cost

Halves the per-login game-loop cost. This does not make hashing cheaper or move it off the loop — that is gated on a measurement described in `docs/handoffs/2026-08-07-off-loop-argon2-hashing.md`.
2026-08-08 09:25:42 -07:00
Guflly
64e6fe5da8
fix: Warn when sending empty gumps (#2563)
### Summary

Generates a console warning when users receive an empty gump. This will help prevent client side leaks.
2026-08-08 00:55:12 -07:00
Kamron Batman
b2c59191bd
fix: Fixes Argon2 verify correctness and the password upgrade lockout (#2562)
> ⚠️ **Rollback hazard — one-way door once logins are taken.** Serialization is unchanged, so a save
> written by this build still *loads* on the previous one. Its contents do not survive the trip: on
> its first successful login each account is rehashed to `$argon2id$`, and the previous build ships
> Argon2.Bindings 1.19.0, whose `Verify` is gated by the verifier's own configured type and answers
> `false` for an `$argon2id$` hash. **After a shard running this build has accepted logins, do not
> roll back past this commit** — every account that logged in is locked out on the older binary, and
> the only recovery is rolling forward again or resetting passwords by hand. Roll back only from a
> save taken before the first post-deploy login.

Requires [Argon2.Bindings 1.20.0](https://github.com/modernuo/Argon2.Bindings/pull/14), now published.

## What

- Consume `Argon2.Bindings` 1.20.0, which resolves the Argon2 type from the stored PHC string rather than from the verifier's own configuration.
- Default to **Argon2id, m=16384, t=1, p=1** — 8.51 ms against the old Argon2i 8 MiB t=3 at 10.11 ms. Cheaper *and* stronger.
- Rehash on a successful login whenever the stored parameters are stale, not only when the algorithm changes.
- Fix `SetPassword`, which derived the password phrase from the outgoing algorithm while storing it under the incoming one.

## Why

**Verification was gated by the verifier's configured type.** `Verify` passed the instance's own `ArgonType` to native `argon2_verify`, whose `decode_string` rejects a disagreeing `$argon2i$`/`$argon2id$` prefix and returns `DECODING_FAIL` — folded into `false`, the same answer as a wrong password. Switching the default type would have locked out every existing account, and `VerifyAndUpdate` could not have migrated them either: it delegates to the same type-fixed `Verify` and never compared `ArgonType`. Fixed upstream in 1.20.0. The pinned legacy-`$argon2i$` test here fails on 1.19.0 for exactly that reason, which is what makes the package bump load-bearing rather than incidental.

**Changing the defaults would otherwise have reached nobody.** Argon2's PHC string embeds `m`, `t` and `p`, so verification uses the parameters stored with each account, not the configured ones — and verification is the hot path. `CheckPassword` only rehashed when the *algorithm* changed, never when its cost parameters did, so on an established shard the new defaults would have applied to new accounts only. `IPasswordProtection.NeedsRehash` closes that: it defaults to `false`, so PBKDF2 and the `HashAlgorithm` protections are untouched — only Argon2 carries its cost inside the stored value.

**`SetPassword` picked the phrase rule from the wrong algorithm.** SHA1 and SHA2 salt the phrase with the username; Argon2 and PBKDF2 do not. It chose the rule from the *outgoing* algorithm while storing under the *incoming* one, so any algorithm change wrote a credential its own next verify could not reproduce. It now assigns `PasswordAlgorithm` first and derives the phrase from that. Note this ordering is load-bearing and invisible — `UpgradingAlgorithm_DoesNotLockTheAccountOut` is what pins it.

## Cost

Verification is re-derivation, so these are login numbers. A full login calls `CheckPassword` twice — `AccountLogin` (0x80) then `GameLogin` (0x91): **~20 ms before, ~17 ms after**, plus a one-time ~8.5 ms rehash on each account's migrating login.

That cost is still paid on the game loop. Moving hashing off-loop is deliberately **not** in this PR — it needs a pending-auth state in the login handlers, bounding of in-flight hashes, and login rate limiting.
2026-08-08 00:24:59 -07:00
Kamron Batman
23dc6649a0
fix: Require only runtime packages on Linux, and check ICU and tzdata the way the runtime does (#2561)
## Why

ModernUO mandated `-dev` packages on production servers for exactly one reason: `DllImport` never
asks for a versioned SONAME, so `libdeflate.so.0` and `libargon2.so.1` sitting in `/usr/lib` went
unfound, and the `-dev` package's unversioned symlink was the only thing making resolution work.
The `-dev` packages ship no library of their own — operators were installing headers and a static
lib on machines that compile nothing.

Fixed in the binding packages (modernuo/LibDeflate.Bindings#4, modernuo/Argon2.Bindings#13), so
this picks them up and stops asking.

```
LibDeflate.Bindings 1.0.3  -> 1.0.4
Argon2.Bindings     1.17.0 -> 1.19.0
```

## zstd is dropped too, on every platform

ZstdNet bundles `libzstd` for `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64` and win, and
nothing shells out to the CLI. Verified: the 15 `ManagedArchive` round-trip tests pass in a
container with no `zstd` package installed and `which zstd` empty. Removed from the README, the
macOS `brew install`, and CI — so the macOS runners now prove it rather than us assuming it.

## NativeLibraryChecker asks a different question

It asked *"is package X installed"* via `dpkg -l` / `rpm -q`. That is what forced `-dev`, and no
hardcoded name works for ICU anyway — its apt package is release-specific (`libicu70` on Ubuntu
22.04, `libicu76` on Debian 13). It now asks *"will the loader find this"*: `NativeLibrary.TryLoad`
on the unversioned name, then `libfoo.so.N` descending through the range the runtime accepts.

It deliberately does not consult a package database or `ldconfig -p`. Both answer a different
question than "will `dlopen` succeed" — see the ICU section below for how that bit.

## What was wrong with the ICU check

`libicuuc` was **inherited, not derived**. It came from translating the old package-name check into
a library probe, without establishing which library that should be. Reviewing it turned up three
defects, all of which could report ICU present on a host where the runtime then refuses to start:

- **`libicui18n` was never probed.** The only ICU names in `libSystem.Globalization.Native.so` are
  `libicuuc` and `libicui18n`. `libicudata` arrives as a dependency of `libicuuc`, and
  `libicuio`/`libicutu`/`libicutest` are never referenced — so that is the complete list, and both
  are checked now.
- **No version floor.** The runtime's `MinICUVersion` is 60, but the probe accepted down to
  `.so.0`. RHEL/CentOS 7 ships ICU 50, which passed and then aborted at startup.
- **The `ldconfig` fast path bypassed the range.** A cache line for `libicuuc.so.50` still matches a
  `libicuuc.so` prefix test, so the floor was unenforceable through it. It also trusts a stale
  cache — observed reporting a deleted `libdeflate` as present. Removed in favour of asking the
  loader directly, which reads the same cache but answers the real question, and which also deletes
  the musl special-case (`ldconfig -p` exits 0 on musl while producing nothing usable).

Worth knowing when this goes wrong in the field: **missing ICU does not throw, it `FailFast`s** —
SIGABRT, exit 134, uncatchable. The process starts cleanly and dies later at whatever line first
touches a culture, so the stack rarely implicates ICU.

## tzdata is a separate prerequisite, and nothing was checking it

The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads
`/usr/share/zoneinfo`. It is data rather than a library, so no loader probe finds it, and slim
container images routinely omit it. Without it every lookup except `UTC` throws
`TimeZoneNotFoundException` and `GetSystemTimeZones()` returns 1 entry instead of ~419.

There is no per-zone packaging to opt into — it is ~2 MB for the whole set. The one split that does
exist is a trap rather than an optimization: Debian 12 and Ubuntu 24.04 move the legacy aliases into
`tzdata-legacy`, so plain `tzdata` has `America/New_York` and `EST5EDT` but is **missing
`US/Eastern` and `Asia/Calcutta`**. A shard configured with a legacy alias throws even though tzdata
is installed. Documented, with both fixes.

## Why `InvariantGlobalization` stays false

Dropping ICU entirely by turning on invariant mode looks tempting and is not safe. Because
`Directory.Build.props` also sets `PredefinedCulturesOnly=false`, invariant mode does **not** throw
`CultureNotFoundException` — it silently hands back invariant data. Measured on .NET 10:

| Behaviour | With ICU | Invariant mode |
|---|---|---|
| `new CultureInfo("de-DE")` | real culture | succeeds, returns invariant data |
| de-DE decimal separator | `,` | `.` |
| `1234.5` as de-DE | `1.234,5` | `1,234.5` |
| `string.Compare("a", "B", InvariantCulture)` | `-1` (linguistic) | `31` (ordinal) |
| sort `[b, A, a, B]` | `a, A, b, B` | `A, B, a, b` |
| `FindSystemTimeZoneById("Eastern Standard Time")` on Linux | resolves | `TimeZoneNotFoundException` |
| UTF-8 round-trip of non-ASCII | unaffected | unaffected |

Number parsing and formatting produce wrong values with no error, and culture-sensitive sort order
silently becomes ordinal. Encoding is not the mechanism — UTF-8 round-trips fine either way.

## Documentation

The rationale now lives in `dev-docs/platform-prerequisites.md` rather than in comments, so it is
discoverable without reading the build tool: what each dependency is for, what breaks without it,
per-distro package names, the ICU floor, the `tzdata-legacy` split, and why the check asks the
loader instead of the package manager.

README drops `libicu-dev`. Matching the runtime package by pattern (`'^libicu[0-9]+$'`) is
version-independent without pulling in headers, so **no `-dev` package is required on any supported
distribution** — which was the point of the whole change.

## CI now proves the claim instead of contradicting it

The dnf job already installed runtime packages only. The apt job installed `libicu-dev`, which ships
the unversioned `libicuuc.so` symlink — so every probe succeeded on the first attempt and the
versioned-SONAME fallback this PR depends on was never exercised. Switched to the pattern match,
verified to resolve exactly one package on jammy (70), bookworm (72), noble (74) and trixie (76).

Added an assertion that the unversioned symlinks are absent. Without it the suite silently stops
testing anything the moment a base image starts shipping one. Verified against all eight matrix
distributions — none ship them — and confirmed the step fails as intended when a symlink is planted.

## Audit of every other native entry point

Checked whether anything else has the same hazard. It does not:

| Import | Verdict |
|---|---|
| `ws2_32.dll` — `SocketHelper` | Always present on Windows |
| `libc` — `SocketHelper` | **Verified safe**, see below |
| ZstdNet → `libzstd` | Bundled for every RID |
| IORingGroup | No native library; raw syscalls |
| ICU | Loaded by the .NET runtime itself, which probes versioned suffixes |

`libc` deserved a hard look, because `libc.so` *is* a `libc6-dev` linker script while the real
library is `libc.so.6` — the same shape as the bug being fixed. It is not affected. Measured in a
container with no `libc6-dev`:

```
/usr/lib/x86_64-linux-gnu/libc.so   ABSENT
/lib/x86_64-linux-gnu/libc.so.6     present
TryLoad("libc")     LOADED      <- resolves where "libdeflate" would not
TryLoad("libc.so")  not found
getpid() -> DllImport("libc") WORKS
```

Confirmed on Alpine/musl as well. No code in this repo registers a `DllImportResolver`, and nothing
else P/Invokes.

## `--check-prereqs`

New flag. `Program.cs` only ran the SDK check in non-interactive mode — `NativeLibraryChecker` was
reachable only through the Spectre-driven guided flow, so there was no way to verify a deployment
target from a script or a container. It is what made the container verification below possible, and
it prints the exact ICU package for the running release via `apt-cache`.

It renders through the same `PrerequisiteChecker` the guided menu uses, rather than a second
hand-rolled table that could drift from it. Spectre drops ANSI styling on its own when stdout is not
a terminal, so redirected output stays clean; the console width is widened in that case so the
install hints, which are shell commands meant to be copied, do not gain a newline mid-command.

```
╭───────────────────────────╮
│ Checking native libraries │
╰───────────────────────────╯

  ✔ libicuuc (Found)
  ✔ libicui18n (Found)
   libdeflate (Not found)
   tzdata (Not found — every zone except UTC will throw)

  ⚠️ Install the missing dependencies. The -dev/-devel packages are not required:
   sudo apt-get install -y libicu74 libdeflate0 tzdata
```

Exit code carries the machine-readable half: 0 when everything resolves, 1 when anything is missing.

## Verification

Against 1.0.4 and 1.19.0: build plus **810 Server.Tests and 642 UOContent.Tests**, on Windows and
on Linux with **only** `libdeflate0` and `libargon2-1` installed — with the absence of the
unversioned symlink asserted first so the run could not pass for the wrong reason.

`--check-prereqs` verified in containers on Debian and Alpine across every state that matters: all
present, each dependency removed individually, tzdata removed, a deliberately stale `ldconfig`
cache, and ICU downgraded to `.so.50` to confirm the floor rejects it. Package resolution and the
absence of unversioned symlinks checked on all eight CI distributions.
2026-08-07 15:03:08 -07:00
Kamron Batman
246f077778
chore: drop the liburing prerequisite, which was never used (#2560)
## Why

`IORingGroup` issues io_uring syscalls directly rather than linking `liburing`, so the package has
never been needed — but we ask operators to install it in the README, install it in CI, and check
for it in `build-tool`.

Verified against the **shipped** `IORingGroup` 1.0.9 assembly, not just the source:

| Symbol | Occurrences in `IORingGroup.dll` |
|---|---|
| `libc`, `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll`, `ws2_32.dll` | present |
| `liburing` | **0** |
| `io_uring_queue_init` — liburing's entry point | **0** |
| `io_uring_setup` — the raw syscall | 1 |

If it linked liburing it would call `io_uring_queue_init` / `io_uring_submit`. It calls neither.

## What changes

Nine lines across three files, removing `liburing-dev` / `liburing-devel` from:

- `README.md` — both the dnf and apt prerequisite blocks
- `.github/workflows/build-test.yml` — both install steps
- `Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs` — the cross-compile target text, the
  apt and dnf package lists, and the `ldconfig` fallback map

Nothing else is touched. `zstd` and the `-dev` packages are a separate discussion and a separate PR.

## Risk

None to the build. `liburing` was only ever installed, never linked or loaded — removing it cannot
change resolution behaviour. `build-tool` builds clean.

This was found while investigating why Linux requires `-dev` packages at all; that fix lives in the
binding packages (modernuo/LibDeflate.Bindings#4, modernuo/Argon2.Bindings#13) and lands separately
once those publish. This piece is independent and unblocked, hence its own PR.
2026-08-06 21:32:25 -07:00
Kamron Batman
6d81077772
perf(network): consume IORingGroup 1.0.9 to drop the per-iteration 6 KiB memset (#2558)
## Problem

`IORingGroup`'s `WindowsManagedRIOGroup.DequeueRioCompletions` stackallocs `RIORESULT[256]` (6144 bytes) and runs **once per game-loop iteration** — `NetState.Slice` → `RingSocketManager.ProcessCompletions` → `PeekCompletions` → `DequeueRioCompletions`.

The 1.0.8 package was compiled with the `.locals init` IL flag set, so every one of those calls memset the full 6 KiB before `RIODequeueCompletion` overwrote the entries it actually filled.

An EventPipe profile of a near-idle shard (3 vCPU VPS, world saves off, one player logging in and moving around) put `System.Buffer.ZeroMemoryInternal` — called directly from `DequeueRioCompletions` — at **~2.8% of main-thread samples**, and it was the dominant frame in several 60–127 ms game-loop stalls.

## Why our existing attribute didn't cover it

`Projects/Server/Module.cs` and `Projects/UOContent/Module.cs` already declare `[module: SkipLocalsInit]`. That attribute is a **compile-time** directive: it clears the flag in the IL of the assembly being compiled, and does not cross assembly boundaries. It never applied to the package.

Verified by reading the shipped IL (`MethodBodyBlock.LocalVariablesInitialized`):

| Assembly | attribute | methods with `.locals init` |
|---|---|---|
| `Server.dll` | present | 0 of 5439 |
| `IORingGroup` 1.0.8 | **absent** | **158** |
| `IORingGroup` 1.0.9 | present | **0 of 389** |

## Testing

Built and tested against the locally-built 1.0.9 package (temporary local feed, not committed):

- `dotnet build -c Release` — **0 warnings, 0 errors**
- `Server.Tests` — **810 passed, 0 failed**
- `UOContent.Tests` — **637 passed, 0 failed**
- Confirmed the `IORingGroup.dll` deployed to `Distribution/` is the fixed build (0 of 389 methods zeroing)

Only the `<PackageReference>` version changes; no source changes on this side.
2026-08-04 20:11:37 -07:00
SynPDX
86df62fd3e
fix(housing): register doors, and stop crashing on client component sheets (#2557)
## Summary

Players could not place **any door** while customizing a house, and placing other pieces could disconnect them outright. Staff saw neither problem: `HouseFoundation.Designer_Build` only enforces `ValidPiece` below `GameMaster`.

Original report and diagnosis by @SynPDX.

## Root cause 1 — no door is ever registered

The retail client's `doors.txt` separates its header rows with lines of **bare tabs** (it is the only sheet that does):

```
int<TAB>int<TAB>...<TAB>string
<TAB><TAB><TAB><TAB><TAB><TAB><TAB><TAB><TAB><TAB>      <-- 10 tabs, not an empty line
Category<TAB>Piece1<TAB>...<TAB>FeatureMask<TAB>Comment
<TAB><TAB><TAB><TAB><TAB><TAB><TAB><TAB><TAB><TAB>
0<TAB>1657<TAB>1659<TAB>...
```

`Spreadsheet.ReadLine` skipped a line only when `line.Length > 0`. A 10-tab line has length 10, so it was returned as the **names row** — every column ended up named `""`, `GetColumnID("Piece1")` and friends returned `-1`, and not one of the 230 door graphics was registered. Unregistered item IDs keep the `-1` sentinel, and `CheckValidity` rejects those, so `ValidPiece` refused every door.

ClassicUO skips these lines (`string.IsNullOrWhiteSpace` in `HouseCustomizationManager.ParseFile`), which is why the client happily offers doors the server then rejects.

Measured against a retail 7.0.x `doors.txt` using the shipped `Spreadsheet`:

| | `FeatureMask` column | door graphics registered |
|---|---|---|
| before | `-1` | **0** |
| after | `9` | **230** |

## Root cause 2 — `IndexOutOfRangeException` out of the packet handler

Every sheet ends in a cosmetic `Comment` column that ModernUO never reads, and client sheets write an empty comment as a plain newline with no trailing tab. `Split('\t')` then returns one field fewer than the header declares, and the parser indexed past the end:

```
System.IndexOutOfRangeException: Index was outside the bounds of the array.
   at Server.Multis.Spreadsheet..ctor(String path)
   at Server.Multis.ComponentVerification.LoadSpreadsheet(...)
   at Server.Multis.ComponentVerification.IsItemValid(Int32 itemID)
   at Server.Multis.HouseFoundation.ValidPiece(Int32 itemID, Boolean roof)
   at Server.Multis.HouseFoundation.Designer_Build(NetState state, ...)
```

The client's own parser only requires the columns up to `FeatureMask` — ClassicUO's `CustomHouseMisc.Parse` guards on `scanf.Length >= 12` for a 13-column `misc.txt` — so such a row is valid data listing real pieces. Missing trailing fields are now treated as empty rather than dropping the row, which would unregister every piece the row lists and reproduce the door symptom.

`EnsureLoaded` also set `_loaded` before loading, so once the throw escaped, an all `-1` table stayed cached and rejected everything for players from then on — the same player-visible symptom as #2500.

## Also made explicit rather than accidental

- **Named the table sentinels.** `NotAComponent` (-1) is the anti-cheat guard and the initial state; `NoFeatureRequired` (0) is a piece with no expansion gate — how `walls.txt` encodes pre-AOS base pieces and what `housing.bin` collapses to under `HousingTierMask` (#2500).
- **A sheet with no `FeatureMask` column is refused and logged.** `GetInt32` on a missing column returns 0 = `NoFeatureRequired`, which would have silently marked every piece in that sheet unconditionally placeable regardless of expansion. This was previously only harmless by accident.
- **A sheet matching none of its expected tile columns is refused and logged** — that is what `doors.txt` was doing silently. Individual missing columns stay tolerated, since older sheets predate columns such as `walls.txt`'s `SecondAltWindowS`/`E`.
- **Catch per sheet**, so one unreadable file no longer costs the other six.
- **Header guards**: an empty file or a types-only file raised a `NullReferenceException`; a names row shorter than the types row indexed past the end.
- **Fall back to the component sheets when `housing.bin` cannot be read**, instead of passing `null` into a `SpanReader`.

`_loaded` is still set before loading, deliberately: this runs from the design packet handler, and retrying would re-read every sheet on each subsequent placement attempt.

Sheet precedence is **unchanged** — the client's copies stay authoritative and `Data/Components` remains the fallback.

## Verification

- Retail 7.0.x client `doors.txt` through the shipped `Spreadsheet`: 0 door graphics before, 230 after.
- 5 new tests in `SpreadsheetTests` covering the tab separators, the omitted trailing field, per-row recovery, and both header guards. All 5 fail against `main` and pass here.
- `dotnet build` clean (0 warnings, 0 errors); `UOContent.Tests` 642/642.
2026-08-04 20:05:28 -07:00
Kamron Batman
aae173a797
feat(network): allowlist false-positive IPs, escalate on behavior (#2556)
## Why

The shard owner, on a Starlink CGNAT address, was blocked by the imported reputation blocklist.

The cause was not CrowdSec. The address was a literal line in `ip-blocklist.txt`, so `BlocklistFilter` denied it at accept and then promoted it — and clearing the CrowdSec decision could not fix it either, because the file entry re-reports within `promoteSuppression` of every reconnect attempt.

This is structural, not a one-off. Reputation feeds list shared consumer address space constantly: on CGNAT one public address fronts many subscribers **at the same time**, so a single abusive customer gets the address listed and everyone else behind it is blocked with them. Where leases rotate, a listing says little about whoever holds the address now. Around 1,000 Starlink addresses sit in the current list.

So exemptions go where they cost nothing, and escalation is driven by what a connection actually does.

## Generator — `tools/Export-IpBlocklist.ps1`

`-AllowlistFile` takes multiple paths, subtracted from the merged set before the output is written. Defaults to every `ip-allowlist*.txt` beside the output, merged into one allow set:

- `ip-allowlist.txt` — operator exemptions, created once and **never rewritten**
- `ip-allowlist-<name>.txt` — a carve-out you built, regenerable and copyable between shards

**Subtraction is range-correct.** An allowlisted address inside a blocked CIDR splits that CIDR around the hole rather than being silently ignored. This also fixes `-ExcludeAnonymizers`, which parsed CIDR entries into `$anonCidr` and then only ever subtracted singles.

**No carve-out ships.** A carve-out names a real network, and which ones a shard should exempt depends on where its players actually are — so publishing one would make that policy call for every shard and put a specific provider's address space in the repo. The script builds them on request instead:

```powershell
.\Export-IpBlocklist.ps1 -AddCarveout starlink -Asn 14593
```

Carve-outs are **discovered, not configured**: every `ip-allowlist*.txt` beside the output is subtracted, by the generator and by the shard, so a file an admin adds needs no config edit and no code change. Each carries an `asn=` marker in its header, which is how `-RefreshCarveouts` rebuilds it without the script keeping a list of anyone's networks; a hand-written allowlist has no marker and is never rewritten.

Prefixes come from **announcements, not ownership records**, because registry data disagrees with what is actually routed and silently caps result sets: ARIN whois returns at most 256 rows and gives per-customer /24s, and `206.83.96.0/19` reads as APNIC in RDAP even though `206.83.96/21` is announced by Starlink.

Editing an allowlist bypasses `-MinInterval`, so a just-added exemption isn't indistinguishable from the allowlist not working. A Starlink carve-out, if you build one, costs **~4,300 IPs + ~144 CIDRs of 4.2M (0.10%)**.

## Allowlists

**`FileAllowlist`** reads the same files the generator subtracts, so an operator entry means "leave this address alone" for real. Subtraction alone only covers being *blocked*; behavioural detections never consult the blocklist, so without this a carve-out was quietly routed around — one scanner behind a shared address was enough to get everyone behind it contributed and firewalled, with nothing in the shard's own config explaining why. Reading the files also means an entry applies on the next reload rather than the next regeneration, which is what matters when someone is complaining now.

**`LoginAllowlist`** is earned by authenticating, with a 90-day TTL because an address that logged in years ago is a stranger. Its own store rather than `Account.LoginIPs`, which has no timestamps and cannot be backfilled. An entry is evidence rather than a licence: 10 suppressed contributions in an hour revokes it, and a fresh login forgives the tally.

Both are consulted **only after the blocklist has already matched**, so a normal accept pays nothing for them and the accept gate stays allowlist-free. `BanExemptions` combines them behind `BanChannel.IsExempt` and suppresses escalation only — every local defence still applies.

Two limits, both deliberate and documented in the class: `LoginAllowlist` **cannot bootstrap** (an entry is only earned by getting in, so it never repairs an existing false positive), and it is weakest on rotating CGNAT. That is why `FileAllowlist` is the fix for those, and why it is manual.

## Behavioural detection

| Reason | Trigger |
|---|---|
| `silent-connect` | Reaped after 5s having sent **zero bytes** |
| `invalid-seed` | Opened with a zero seed |
| `foreign-protocol` | Positively identified as HTTP, TLS or SSH |

**`ForeignProtocol` inverts the test.** Asking "is this a good UO client?" cannot work: `LoginEncryption.ClientDecrypt` is a byte-for-byte stream XOR, so a legitimate client with encryption enabled when the shard expects none sends a structurally perfect connection whose payload is noise. "Speaks HTTP" is safe where "unreadable" is not — however misconfigured a UO client is, it never sends `GET / HTTP/1.1`.

Nothing assumes arrival framing. TCP has no message boundaries, so a rule of the form "these bytes must arrive together" is broken by construction and drops real players on poor links. A prefix match with too few bytes to confirm waits for more. A four-byte seed can legitimately spell `GET ` (the address 71.69.84.32) or `0x16 0x03 0x0?` (22.3.x.x), so confirmation requires the request line to continue in printable ASCII or an actual ClientHello inside a plausible record — a real client's fifth byte is a packet id (`0x80`, `0x91`, `0xEF`), none of them printable, so those collisions fall through.

Everything is keyed on **bytes-received rather than elapsed time**. A connection that sent something and ran out of time is far more likely a slow link than an attack, and banning those produces the worst failure mode available: the player retries, trips the rate limiter, and compounds a bad connection into hours of being firewalled off.

## `AutoDenylist`

A short-lived local hold (15m) on behavioural detections, as `IConnectionFilter` + `IBanReporter` over one store so the engine detection sites never reach into content.

This closes the gap where a flood pays for a socket, buffer and `NetState` slot per connection while waiting for the OS bouncer — the verdicts that matter most are reachable only *after* reading bytes — and it is the entire defence on a shard running no bouncer, which is the default config. Not persisted: a holding pen that survives restarts is a ban without a ban's review.

Cost: one dictionary lookup on a usually-empty dict per accept.

## `BanReasons`

Centralises the reason slugs. `IsBehavioral` is an **opt-in** set, not "everything except manual", so a future reason escalates normally instead of silently inheriting an exemption or entering a local denylist.

This caught a real bug during review: the first cut of the exemption swallowed `manual` admin bans (`Commands.cs`, three sites in `AdminGump`) for any allowlisted address.

## Fixes found in review

- **`BanConfiguration.Settings` was null until `Configure()` ran**, while the reap path dereferences it every `Slice()`. A harness driving `NetState.Slice()` directly hit an NRE that presented as flaky because it depended on whether an earlier test had already called `Configure()` — which is why it failed on some CI platforms and not others. Now starts at the record's defaults, with idempotency tracked by a flag; this also removes the same latent NRE from the pre-existing rate-limit path.
- **`-AllowlistFile` was typed `[string]`** while documented and used as a list, so passing two paths would have collapsed them into one string.

## Layout and docs

Content network code moves out of `Misc/` into `UOContent/Network/`, one concern per folder — `AutoDenylist/`, `Blocklist/`, `CrowdSec/`, `Firewall/`, `LoginAllowlist/`, `Packets/`. **Namespaces are untouched**, so these are pure file moves (git tracks all 16 as renames).

`dev-docs/ip-bans-and-allowlists.md` documents the subsystem, leading with the operator process for unblocking a player — including the three things that look sufficient and are not: deleting the CrowdSec decision alone, editing `ip-blocklist.txt` by hand, and `cscli allowlists` alone. `.gitignore` covers the new config files.

## Testing

Build clean. **Server.Tests 810 passed**, **UOContent.Tests 637 passed**, zero warnings. This branch adds 38 tests; the rest of the delta is main's, since this is rebased on current `main`.

New coverage: TTL boundary and renewal, private-address exclusion, manual-ban-never-exempt, unopted-reason-never-exempt, strike revocation, quiet-window reset, login forgiveness, file-allowlist CIDR coverage, file-allowlist not spending the earned list's strikes, denylist expiry-on-read, cap enforcement, lapsed-entry reclaim, HTTP/TLS/SSH identification, seed-collision fall-through, and encrypted-login-is-not-foreign.

Generator verified end-to-end against live feeds: a clean run ships no carve-out, `-AddCarveout starlink -Asn 14593` fetches and collapses 213 prefixes to 115 ranges in 0.1s over 4.2M entries, `-RefreshCarveouts` rediscovers it by its `asn=` marker, a hand-written allowlist is left untouched, and deleting a carve-out drops it rather than having it rewritten. CIDR splitting verified exhaustively: a single-IP hole in a /24 leaves exactly 255 of 256 addresses blocked.

## Operator note

Existing installs are unaffected until the generator next runs, which creates `ip-allowlist.txt` and nothing else. To unblock someone: add the address to that file and delete any live CrowdSec decision — the existing ban outlives the config change. The shard picks the entry up on its next reload, so re-running the generator is optional.

A shard whose players are on CGNAT (satellite, mobile, or an ISP short on IPv4) will likely also want `-AddCarveout`; see `dev-docs/ip-bans-and-allowlists.md`.

## Also included: a latent CI failure this PR surfaced

`fix(tests): serialize test classes that rent through STArrayPool` touches a property-list test file that has nothing to do with this feature. It is here because it was failing macOS CI, and it is trivially cherry-pickable out if you would rather it went to `main` on its own — **which may be the better call, since it is failing `main` today.**

CI has since gone green with it applied.

`STArrayPool` is single-threaded by design and its bucket cache is a plain `static`, not `[ThreadStatic]`, with a check-then-act initialize in `Return()`:

```csharp
var cacheBuckets = _cacheBuckets ?? InitializeBuckets();
```

Two threads both see null, both initialize, and the loser trips `Debug.Assert(_cacheBuckets is null)`. Anything renting from it has to stay off parallel test threads — which is what the `DisableParallelization` collections are for.

- `ObjectPropertyListReentrancyTests` and `ObjectPropertyListNestedBuildTests` (added in #2555) build property lists, which rent the interpolation buffer, but were not in the sequential collection — unlike `PropertyListInvalidationDuringBuildTests` in the same file. This is a **latent failure already on `main`**; it is timing-dependent, so it shows on some platforms and not others.
- `AutoDenylistTests` (added here) has the same exposure: its cap tests reach `AutoDenylist.Sweep`, which rents a `PooledRefList` without `mt`. The blocklist tests need no marking because `BlocklistSnapshot.Build` asks for the `mt` pool explicitly.

No production change — `STArrayPool` is the right pool on the game loop, where both `Sweep` and the property list actually run.

## Deliberately not included

Waiting for a fragmented four-byte seed at `AwaitingSeed`. It looked like a bug but the disconnect is a deliberate defence: only pre-0xEF clients reach it (0xEF goes through `HandlePacket`, which already waits for its 21 bytes), and waiting converts an instant drop into a full 5s slot hold for a client sending one or two bytes, or a loris dribbling a byte every few seconds. Against a fixed 4096-entry `MaxConnections` table that trades capacity that matters for a fragmentation case a reconnect already fixes.
2026-07-30 23:12:17 -07:00
Kamron Batman
b8d3fec59a
fix(opl): refuse property list invalidation raised from inside GetProperties (#2555)
## The bug

Any property getter reached from `GetProperties` that calls `InvalidateProperties` takes the tooltip build down with it:

```
System.ArgumentNullException: Value cannot be null. (Parameter 'array')
   at Server.ObjectPropertyList.AppendStringDirect(String value)
   at Server.Mobiles.PlayerMobile.GetProperties(IPropertyList list)
```

`InvalidateProperties` rebuilds **in place** — `Reset()`, then `GetProperties()` again on the same instance — and `Reset()` does two destructive things to a build already in flight:

1. **It returns the pooled interpolation buffer.** The compiler rents it in the handler ctor and returns it in the closing `Add`, so *every hole is evaluated while it is live*:

```csharp
var handler = new InterpolatedStringHandler(1, 2, list); // InitializeInterpolation() RENTS
handler.AppendFormatted(pl.Rank.Title);                  // <-- getter runs HERE
handler.AppendLiteral("\t");
handler.AppendFormatted(faction.Definition.PropName);
list.Add(1060776, ref handler);                          // consumes span, RETURNS
```

```
GetProperties(list)
├─ InitializeInterpolation()  -> _arrayToReturnToPool = Rent(256)     buffer LIVE
├─ « hole 1: pl.Rank.Title »
│  └─ PlayerState.Rank.get   (lazy recompute)
│     └─ Invalidate() -> InvalidateProperties() -> m_PropertyList.Reset()
│        └─ Dispose(): Return(buf); _arrayToReturnToPool = null        buffer GONE
└─ handler.AppendFormatted("Knight")
   └─ _arrayToReturnToPool.AsSpan(_pos..)
      └─ ArgumentNullException (Parameter 'array')
```

It surfaces as `ArgumentNullException` rather than `NullReferenceException` because the `Range` overload of `AsSpan` must read `array.Length`, so the BCL null-checks and names the parameter `array`.

2. **It rewinds the packet cursor**, so properties already written are overwritten by the nested pass — a silently corrupted tooltip even where the buffer survives.

## The fix: refuse, don't recover

There is no correct recovery, and retrying the build would only hide the defect. A nested invalidation now logs an error with a stack trace, **throws in `DEBUG`** so it gets found and fixed, and in `RELEASE` returns without touching the list — a possibly stale tooltip, but no crash, no corrupted packet, and nothing leaked back to the pool. Getters that genuinely must invalidate should defer:

```csharp
Timer.DelayCall(InvalidateProperties);
```

The guard flag lives on the `ObjectPropertyList`, not the entity: it is that list's own lifecycle, it costs nothing (both `Item` and `ObjectPropertyList` absorb it in existing padding, and the list is allocated lazily), and it stays correct when builds for different entities nest.

Base instance sizes are unchanged from `main`: Item 128 B, Mobile 792 B, ObjectPropertyList 72 B, PlayerMobile 1216 B.

`PropertyList` also publishes the list into `m_PropertyList` **before** building it rather than assigning through `??=` afterwards, so a nested `InvalidateProperties` sees the build in progress instead of recursing into a second throwaway list whose work is discarded.

`ObjectPropertyList` re-rents its scratch buffer instead of spanning a null array, so a stray `Reset()` from any other caller degrades rather than aborting `GetProperties`.

## Factions `PlayerState`: maintained, not lazily computed

The getter that surfaced this is now a plain field read — the whole `if (m_InvalidateRank)` block and the flag itself are gone:

```csharp
public RankDefinition Rank => m_Rank;
```

`UpdateRank()` recomputes at each point an input actually changes:

| Site | Why |
|---|---|
| `RankIndex` setter | this player's index changed |
| end of `KillPoints` setter | two paths write `m_RankIndex` directly, bypassing the setter; runs once the swap bookkeeping and `ZeroRankOffset` have settled |
| `Faction.AddMember` | *after* the insert — the member count is not settled during the ctor |
| `FactionState` load | once ordering and `ZeroRankOffset` are final |

Supporting fixes this forced out:

- **Both ctors seed the lowest rank.** Nothing recomputes on read any more, so `Rank` has to be usable immediately — including for members that never get a `RankIndex` assigned, which is *every member with no kill points*. Without this, `Rank.Title` NREs.
- **`Rank` always resolves.** Ranks are ordered by `Required` descending ending at `0`, so a *negative* percent (`RankIndex` out of sync with `ZeroRankOffset`) matched nothing and left `m_Rank` null. It no longer divides by a zero `ZeroRankOffset` either.
- **A pre-existing staleness bug.** The `KillPoints` setter writes `m_RankIndex` directly in two places, so the cached rank was never refreshed when a player crossed zero kill points.

All six readers of `Rank` were checked; none relied on the old side effect.

One behaviour change worth flagging: rank refreshes are now **eager** where they used to be lazy, so a `KillPoints` change invalidates each swapped player as it happens. The swap loops break as soon as ordering is satisfied — typically 0–2 swaps — but it is on the path that runs on every faction kill.

## Documentation

The rule is written down so it is enforceable rather than folklore:

- **CLAUDE.md** audit rule 19
- **`dev-docs/property-lists.md`** — new "Never Invalidate From Inside `GetProperties`" section with the failing/passing pattern
- **`dev-docs/claude-skills/modernuo-property-lists.md`** — key rule + anti-pattern
- **`dev-docs/claude-skills/modernuo-code-audit.md`** — rule 19, ERROR severity

## Tests

- `ObjectPropertyListReentrancyTests` — `Reset()` and `Dispose()` re-entered mid-hole (both red against `main` with the exact exception above), nesting behaviour, and the new contract: `DEBUG` throws, `RELEASE` survives, and the build is never retried into a loop.
- `FactionRankTests` — `Rank` is populated before anything reads it, tracks `RankIndex` without a read, is stable across reads, and still resolves when `RankIndex` is out of sync with `ZeroRankOffset`. Red-verified: removing the ctor seed fails the first one.

793/793 `Server.Tests` and 608/608 `UOContent.Tests` pass.

## Noted, not addressed here

`~ObjectPropertyList()` returns the rented array to `STArrayPool<char>.Shared` from the **finalizer thread**, and that pool is single-threaded by design. Left alone as a separate concern.
2026-07-28 21:29:03 -07:00
Kamron Batman
967ddf48fa
fix(crowdsec): send a payload LAPI accepts (500 on alerts, 401 on auth) (#2553)
## Problem

Contributing a ban to CrowdSec failed against a real LAPI — `POST /v1/alerts` answered **500**, and depending on the shard's locale, auth answered **401**. Three independent defects, each sufficient on its own.

## Fixes

**`scenario_hash` / `scenario_version` were never serialized.** LAPI dereferences both unconditionally when persisting an alert, so omitting them is a nil deref and a 500 rather than a validation error. Both are now emitted with the values a watcher without a hub scenario is expected to send (`""` and `"1.0"`).

**`start_at`/`stop_at` were formatted without an `IFormatProvider`.** `:` is the time separator *specifier* in a custom .NET format string, not a literal — a shard running under a culture like `fi-FI` emitted `T15.04.05.123Z`, which Go's `time.RFC3339` rejects, producing another 500. Non-Gregorian cultures (`th-TH`, `ar-SA`) would also shift the year. Formatting is now pinned to `InvariantCulture` in `FormatTimestamp`, which additionally converts non-UTC input — the trailing `Z` is a literal and was previously an unchecked claim.

**The `User-Agent` was a plain product string.** LAPI's default watcher profile matches the `crowdsec/` prefix and answers 401 without it, so the header is a protocol constraint, not cosmetic. It is now an `internal const` carrying that reason.

Also fixed, same root cause as the timestamp bug: the login-expiry parse used a bare `DateTime.TryParse` on LAPI's RFC3339 `expire`. Under a mismatched culture that silently fails and falls back to a fabricated `UtcNow + 1h`, pushing re-auth past the real expiry and costing a 401-relogin round trip on every send.

`capacity` now defaults to `1` instead of `0`, matching the one-decision-per-alert shape actually being sent.

## Note on scope

The two 500 causes are independent. On an `en-US` shard only the missing scenario fields were biting; the date bug was latent and would have surfaced as an unexplained regression the first time someone ran a shard under a European locale.

## Verification

The emitted payload is field-for-field identical to a hand-verified request that a live LAPI accepts:

```json
[
  {
    "scenario": "modernuo/rate-limit",
    "scenario_hash": "",
    "scenario_version": "1.0",
    "message": "ModernUO rate-limit ban for 192.0.2.123",
    "events_count": 1,
    "start_at": "2026-07-27T15:04:05.123Z",
    "stop_at": "2026-07-27T15:04:05.123Z",
    "capacity": 1,
    "leakspeed": "0s",
    "simulated": false,
    "events": [],
    "remediation": true,
    "source": { "scope": "Ip", "value": "192.0.2.123" },
    "decisions": [
      {
        "origin": "modernuo",
        "type": "ban",
        "scope": "Ip",
        "value": "192.0.2.123",
        "duration": "300s",
        "scenario": "modernuo/rate-limit"
      }
    ]
  }
]
```

Regression tests assert the required scenario fields on the **serialized JSON** rather than the DTO — the DTO is not what goes on the wire — and cover the timestamp as a `[Theory]` across `fi-FI`/`th-TH`/`ar-SA`.

`dotnet test --filter "FullyQualifiedName~CrowdSec"` → **21/21 passed**, build clean with 0 warnings.
2026-07-27 23:31:37 -07:00
Kamron Batman
294dcd94a0
fix: Fixes send-path backpressure: consume IORingGroup 1.0.8, stop dropping packets silently (#2551)
## Summary

Two related fixes on the outbound path:

1. Consume **IORingGroup 1.0.8**, which allows more than one send in flight per socket, and expose the two settings that go with it.
2. Stop `NetState.Send` silently discarding packets when the send buffer fills — including an out-of-bounds write reachable in that state.

## 1. Send-path stall (RIO)

RIO reports send completion on **acknowledgement**, not on copy, so a completion cannot arrive sooner than one round trip. With one send in flight, `PostSend` refused to post again until the previous completion arrived — capping a connection at **one send per RTT** whenever it had data queued.

Measured on a 50ms-RTT production shard:

| | before | after |
|---|---|---|
| in-game latency, data flowing | **101–146 ms** | **48–51 ms** |
| p95 | ~135 ms | 52.8 ms |
| samples > 70 ms | 20 | **0** |

The control that confirms the mechanism: server-side post→completion was **unchanged** at median 92ms across both runs. The ACK-binding is inherent to RIO and did not move; only its propagation into application latency did.

Two things worth recording, because they explain why this went unnoticed:

- As little as **6 bytes** of queued data held the gate shut, so it reproduced in empty areas, not just crowded ones.
- The same measurement at loopback RTT is **microseconds**, so local testing could never surface it.

New settings, both restart-time:

- **`network.maxOutstandingSends`** (default 32) — sends in flight per connection. Honoured by RIO only; other backends complete sends on copy and report 1. Costs a request-queue and completion-queue slot per send, **not another buffer**, since every outstanding send addresses a different range of the same registered buffer. Worst-case added latency is roughly `completion RTT / value`.
- **`network.sendBufferSize`** (default 256KB) — per-connection send buffer, coerced to a power of two of at least the platform allocation granularity. This is the lever for the disconnects below, and the per-connection memory ceiling.

## 2. Send buffer full

`NetState.Send` had three failure modes once the buffer filled, none of them visible:

| writable | behaviour |
|---|---|
| `0` | `GetSendBuffer` returned false → **packet dropped**, no log, no disconnect |
| `4 … needed-1` | `Compress` returned 0 → `CommitWrite(0)` → **packet dropped** the same way |
| `1 … 3` | `safeOutputLength = (nuint)output.Length - 4` **underflows** → hot-loop bounds check never trips → **writes past the span** |

The first two leave a client connected while quietly missing game state, which is undiagnosable from either end. The third corrupts the in-flight region of the ring buffer, and is reachable precisely when a connection is congested, since callers only check for non-zero space.

`Compress` now refuses an output too small to bound, and `Send` reports exhaustion instead of dropping — logging and disconnecting with **needed / writable / unacked / capacity**. Those numbers separate a slow client holding the buffer from a buffer genuinely too small for the shard, which is the case that warrants raising `network.sendBufferSize`.

## Testing

`NetworkCompressionBoundsTests` covers the underflow using sentinel bytes around the output window. **Verified to fail without the guard** (4 failures from overwritten sentinels), confirming the out-of-bounds writes were real rather than theoretical.

Full suites green: **788 Server.Tests**, **597 UOContent.Tests**, Release build clean against the published 1.0.8.

## Notes for reviewers

- Upstream change: modernuo/IORingGroup#9.
- The buffer-full path is now *loud* where it used to be silent. If a shard has been quietly dropping packets under load, this will surface as disconnects — that is the intended outcome, and the log line says which setting to raise.
- Follow-up under discussion: promoting a connection to a larger buffer instead of disconnecting, which looks feasible on a live connection since buffers are referenced per-operation rather than bound to the request queue.
2026-07-27 23:06:53 -07:00
Kamron Batman
c909ed1f2f
fix: Streamlines insurance. Insurance only executes when enabled. (#2550)
### Summary

Moves inventory insurance out of `Mobile`/`PlayerMobile` into its own system at `Projects/UOContent/Engines/Insurance/`, wires it into the feature flag system, and makes disabling it actually disable it everywhere.

### Changes

**New `Server.Engines.Insurance.Insurance` system**

* Owns its own `Configure()`, seeding from the existing `insurance.enable` setting (default `Core.AOS`), so no config migration is needed. `Mobile.InsuranceEnabled` is gone, along with its line in `ExpansionConfiguration`.
* `CanInsure`, `GetInsuranceCost`, `ToggleItemInsurance`, `AutoRenewInventoryInsurance`, `CancelRenewInventoryInsurance` and `OpenItemInsuranceMenu` move here from `PlayerMobile`, which keeps four one-line shims for the context-menu callbacks.
* Every entry point is gated on `Insurance.Enabled`, and the death-time state is only allocated when insurance is on — a shard without insurance pays nothing for it.

**Feature flag integration**

Insurance is now a first-class feature flag: `ServerFeatureFlags.InsuranceEnabled`, registered under the `insurance` key in `FeatureFlagManager.SyncStaticFlag`, so it can be inspected and toggled through the normal flag command/gump rather than only at boot. `Insurance.Enabled` reads through to the flag, so there is one source of truth for every consumer.

**Fixes a memory leak from PvP**

`PlayerMobile.m_InsuranceAward` was a `Mobile` field assigned on every death and never cleared, so every player permanently pinned a strong reference to the last player who killed them. Killers were kept alive by their victims indefinitely.

Death-time insurance state now lives in a `Dictionary<Mobile, InsuranceContext>` owned by the insurance system: the entry is created in `OnBeforeDeath` and removed in `OnDeath`, so nothing outlives the death that created it.

**Removes insurance fields from every PlayerMobile**

`m_InsuranceAward`, `m_InsuranceBonus` and `m_NonAutoreinsuredItems` were carried by every `PlayerMobile` whether or not the shard ran insurance. All three are gone; the equivalent state is allocated per-death, only for players who actually die with insured items, only when insurance is enabled.

**Stale `Insured` flags are inert when insurance is off**

`Item.Insured` is a persisted flag, so items stay marked after a shard turns insurance off. Every read path now checks the flag first, so those items behave exactly as if they were never insured:

* `Item.CheckBlessed` / `Item.IsStandardLoot` — they drop again instead of acting blessed
* `Item.AddLootTypeProperty` — no more phantom "insured" tooltip
* `PlayerMobile.FindItems_Callback` — not yanked out of nested bags on death
* `DestroyEquipment` — no longer immune
* `ClothingBlessDeed` — no longer reports "that item is already blessed"

**Gumps promoted out of `PlayerMobile`**

`ItemInsuranceMenuGump`, `ItemInsuranceMenuConfirmGump` and `CancelRenewInventoryInsuranceGump` were private nested classes reaching into `PlayerMobile` privates. They are now public types in `Engines/Insurance/Gumps/`, talking to the insurance system through its public API. `ItemInsuranceMenuGump.ToggleSelected()` replaces the confirm gump's reach-in to the parent's `_items`/`_insure` arrays.

### Behavior changes

* The per-item "You lack the funds to purchase the insurance" message on failed auto-renewal is no longer sent during death; players get the single 1061115 summary instead. Marked with a TODO pending a decision on whether the per-item message should spam.
* The killer's insurance bonus is deposited once at the end of death processing rather than 300 gold at a time per insured item, and the "gold has been deposited" message is now conditional on the deposit succeeding. Same total.

### Drive-by cleanups

`PoisonImpl.IncreaseLevel` -> `Poison.IncreaseLevel`, a redundant `is NetState { } ns` pattern, `new List<Item>(Items)` -> collection expression, alignment of the `SyncStaticFlag` switch arms, and some comment/formatting fixes in `PlayerMobile`.
2026-07-26 09:47:49 -07:00
Kamron Batman
1a9cec1dbb
fix(advancedsearch): clear pause and sample exit before signaling the drain (#2549)
## Summary

`AdvancedSearchThreadWorker.Execute` signals `_stopEvent` **before** clearing `_pause` and **before** reading the exit condition. `Sleep()` unblocks the instant that signal fires, so the owning thread can begin the next cycle while the worker is still finishing the previous one — and the worker's two trailing operations then land on the new cycle's state.

`SerializationThreadWorker` already orders the same handshake correctly and documents why (`Projects/Server/Serialization/SerializationThreadWorker.cs`):

```csharp
// The owning thread may start another pause cycle the moment _stopEvent is set
// (Exit does exactly that). Clear _pause and sample the exit condition before
// signaling, or the new cycle's pause request is clobbered / its Sleep orphaned.
var exiting = Core.Closing || worker._exit;
Volatile.Write(ref worker._pause, false);
worker._stopEvent.Set();
```

This applies the same ordering to the search worker. Three lines; no behavior change on the happy path.

## The two failures

**Reuse hang.** The next cycle's `Wake`/`Push`/`Sleep` writes `_pause = true`, then the worker's stale `_pause = false` lands on top of it. The inner loop never observes `pauseRequested`, its queue is already empty, and it spins on `Thread.Yield()` forever — so the owning thread's next `Sleep()` waits on a `_stopEvent` that is never set again. A single search wakes each worker exactly once, so this only surfaces once `_threadWorkers` is reused by a later search.

**Orphaned `Exit()`.** `Exit()` sets `_exit`, `Wake()`s, then `Sleep()`s — the moment the drain's `Sleep()` returns. Reading `_exit` *after* the signal, the worker can observe that fresh `_exit`, return without ever consuming the `Wake`, and leave `Exit()`'s `Sleep()` waiting on a signal nobody will send. The `_thread.IsAlive` guard doesn't close this: the thread passes the check and returns immediately after.

## Verification

Verified with two throwaway timing tests — 25k reuse cycles and 2k drain-then-`Exit` cycles, each under a bounded wait:

| ordering | result |
|---|---|
| previous | `Failed: 2, Passed: 3` — both reproduce, cleanly at the 20s bound |
| this PR | 3 consecutive runs, 5/5, ~0.6s |

**Those tests are deliberately not included.** Their reproduction threshold is a property of one machine's scheduler — at 2k and 200 cycles the buggy build passed — so as permanent tests they'd cost ~560ms and 2000 thread creations on every suite run for a guarantee that may not hold on a CI runner. The ordering is protected the same way `SerializationThreadWorker`'s is: by the comment at the call site.

`UOContent.Tests`: **597/597**.
2026-07-25 15:32:06 -07:00
Kamron Batman
9c11ccdb80
fix(pathfinding): stop opening every .swb twice at boot (#2548)
## Problem

Every map's `.swb` step cache was opened, indexed and logged **twice** on boot.

`MovementPath.Configure()` explicitly called `PathCacheCommands.Configure()` and `CacheEvictionTimer.Configure()`. Both are types exposing a public static parameterless `Configure()`, which `AssemblyHandler.Invoke("Configure")` already discovers and calls once each (`AssemblyHandler.cs:157`). So `PathCacheCommands.Configure()` ran twice, and `AutoLoadAtStartup()` with it. `PathCacheCommands.Configure()` called `PathfindRecorder.Configure()` the same way.

`TryOpenLazyReader` disposes the prior reader before replacing it, so there was no handle leak — but the header and full chunk index of each `.swb` were read twice (~48 MB of files across six facets). The expensive `.mul` hashing was already memoized, so it was not doubled.

## Fix

Consolidate the cache lifecycle into `Initialize`:

- `Configure()` keeps only settings and command registration.
- `Initialize()` opens the readers once, then prebakes only maps that still lack one.
- The post-bake reopen is per-map instead of a blanket `AutoLoadAtStartup()` — on a partial bake (some valid `.swb`, one stale) that would close and reopen the readers already open, a second double-open on a different path.

`Initialize` is the correct phase. `Configure` runs before `TileMatrixLoader.LoadTileMatrix()` and `World.Load()` (`Main.cs:458/460/463/465`), so opening a `.swb` there forced the lazy `Map.Tiles` property — the fingerprint hashes the map files — and built every `TileMatrix` ahead of the loader that owns it, possibly before `TileMatrix.Configure()` settled `Pre6000ClientSupport`. Both sit at the default call priority and the phase sort is unstable. Moving pathfinding out leaves nothing in `Configure` that touches `Map.Tiles`, closing that hazard; the other 22 `.Tiles` users in UOContent are all runtime paths.

Multis stay out of the bake by design — houses and boats are player data that moves, handled by the multi-aware path at query time.

## Logging

The per-map `StepCache: opened ... chunks indexed` line drops to `Debug`. Opening is the expected case; `BakeMap` already logs a rebuild at `Information`, and `Initialize` still emits `PathBake: pre-bake complete (N map(s) written)`.

## Verification

- `dotnet build Projects/UOContent` — 0 errors, 0 warnings.
- `dotnet test --filter FullyQualifiedName~Pathfinding` — **123 passed, 0 failed**.

Boot logs should now show one `opened` line per map at `Debug`, none at `Information`.
2026-07-25 13:07:20 -07:00
Kamron Batman
c39454137e
feat(network): pluggable connection filters; file blocklist + contribute-first CrowdSec (#2542)
Reshapes IP banning around one idea: **core owns the question, content owns every answer.**

Core gains a single accept-path seam — `IConnectionFilter` — and loses everything that used to implement one. The firewall moves to UOContent, a new file-backed blocklist joins it there, and CrowdSec is repositioned from an in-app enforcer to a contribute-first reporter.

## The seam

```csharp
public interface IConnectionFilter
{
    string Name { get; }
    void Configure();
    void Start(CancellationToken token);
    void Stop();
    bool ShouldDeny(IPAddress address);
}
```

The accept path went from hardcoded branches to one question:

```csharp
else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy))
{
    logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy);
}
```

Filters register during the Configure sweep. The registry is a plain array walked by an indexed loop — no enumerator, no closure, no allocation — and the first denial short-circuits. An interface dispatch is noise next to the `accept()` syscall, so pluggability costs nothing measurable on the path that has to survive a DDoS.

Whatever a hit implies — persisting, promoting to an OS bouncer, contributing to the ban channel — is the filter's business, not the accept path's.

A filter that throws is **unregistered and the connection fails open**. A filter that faults once faults for every subsequent connection, so leaving it registered means an exception and a log line per accept — exactly the amplification an attacker wants — and a broken filter must not be able to deny everyone either.

This deliberately does **not** reuse `EventSink.InvokeSocketConnect`: that fires later and allocates a `SocketConnectEventArgs` per connection, which is what the accept path avoids for rejected traffic.

## What ships behind it

**`firewall`** (UOContent) — the existing admin-curated set. Collapsed from `Firewall` + `AdminFirewall` + a threaded enforcer into one single-threaded store with **zero concurrency primitives**: the accept path, admin gump, TTL expiry and boot load all run on the game loop. Persists to `Configuration/firewall.json` with automatic migration from the legacy `firewall.cfg`. No behavior change for operators — same namespace, same gump, same commands.

**`blocklist`** (UOContent) — new. Holds a millions-strong list in-app and **demand-pages** hits up to CrowdSec, which promotes them to the OS firewall.

The motivation is concrete: CrowdSec's Windows bouncer cannot load the ~3.9M IPs that 91 community feeds produce, but it handles ~100k fine. So the millions live in-process behind a binary search, and only addresses that *actually connect* get promoted. A `PromotedGuard` suppresses re-reporting an address until the bouncer picks it up.

The list is parsed straight from UTF-8 file bytes with no per-line string allocation, off the game loop, and published as an immutable snapshot swapped through a single `volatile` reference. Reloads yield to world saves.

**`tools/Export-IpBlocklist.ps1`** — the producer. Requires PowerShell 7 and runs on Windows, Linux and macOS; Windows PowerShell 5.1 is refused up front via `#requires`. Merges a thin, non-overlapping feed set into one de-duplicated, bogon-filtered file. Parsing runs in a compiled `Add-Type` hot loop (~1s for ~4M lines instead of minutes). Written to a `.tmp` sibling and swapped with `File.Replace`, so the shard never reads a half-written list, and a total feed outage refuses to overwrite a good list with an empty one. Re-running is idempotent — it exits without downloading anything while the list on disk is younger than `-MinInterval` (default 2h, the anchor feed's own refresh period), so a misconfigured scheduler can't hammer upstream.

## CrowdSec: contribute-first

`IBanReporter` + `BanChannel` fan locally-decided bans out to external systems. `CrowdSecReporter` (UOContent) posts to LAPI `POST /v1/alerts` and retracts via `DELETE /v1/decisions`.

Reporting is **enqueue-only** on the accept path: a bounded, coalescing channel drained off-loop with bounded retry, counted drops on overflow, and a flush on shutdown. Under a DDoS the accept path never does synchronous or lock-contending per-IP work.

### Why not pull decisions from CrowdSec?

The original design streamed decisions into an in-app snapshot and enforced them at the accept gate. That's the wrong layer: by the time the shard sees the connection, the TCP handshake and socket setup are already paid for. `cs-firewall-bouncer` drops the same traffic **at the kernel**, and it's what CrowdSec is built to do. So the shard now contributes what it uniquely knows (rate-limit trips, blocklist hits from real connection attempts) and lets the OS enforce.

The one thing the OS can't do — hold millions of entries on Windows — is exactly what the in-app blocklist covers, and it feeds the same pipeline.

## Threading policy

`CLAUDE.md` rule #3 is rewritten as an explicit three-part policy, with rule #10 restated in tandem:

- Anything touching game state runs **only** on the main loop.
- Heavy work that *needs* game state must be **chunked** across ticks, never threaded.
- Heavy work that does *not* need game state (large-file parse, external I/O) **must** run off-loop **and must yield to world saves**.

Results come back via an immutable snapshot swapped through a single `volatile` reference, or `Core.LoopContext.Post` — never by letting the scheduler decide where heavy work runs. Both new subsystems follow it.

## Shared primitives

`SortedRangeIndex<T> where T : IBinaryInteger<T>` — coalesced disjoint interval arrays plus a binary search. The firewall, the blocklist, and (as of this PR) core's reserved-network tables all use it.

Coalescing is a correctness requirement, not an optimization: multi-feed lists nest CIDRs (`/24` containing a `/32`), and a search that inspects only the rightmost run whose minimum is ≤ the value is sound **only** over disjoint runs. That bug was caught in review and is covered by regression tests.

`IPAddressUtility` collects the allocation-free `IPAddress` ↔ `UInt128` conversions and CIDR parsing that were previously scattered or duplicated.

## Config

| File | Owner | Keys |
|---|---|---|
| `Configuration/bans.json` | core | `reportRateLimitTrips`, `autoBanDuration` |
| `Configuration/blocklist.json` | content | `file`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` |
| `Configuration/crowdsec.json` | content | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` |
| `Configuration/firewall.json` | content | persisted firewall entries (migrated from `firewall.cfg`) |

Everything is inert by default. CrowdSec self-disables without credentials; the blocklist self-disables until its file exists. A shard that changes nothing sees no behavior change.

## Notes for review

- **Core no longer references `Firewall` or `IFirewallEntry` anywhere.** `NetworkUtilities` used to build its reserved-network tables out of `CidrFirewallEntry`, which coupled core to the firewall for something unrelated to banning; those are now a `SortedRangeIndex<UInt128>`, same semantics and public API.
- **`BanChannel.Stop()` no longer persists the firewall** — a contribution coordinator has no business saving an enforcement store. That's the firewall filter's `Stop()`.
- **A dead `whitelisted` parameter was dropped** from the blocklist gate: it was hardcoded `false` at its only call site, and no whitelist concept exists in core.
- **The blocklist filter is an instance, not a static.** The static version forced its tests onto the sequential collection with a reset hook; they now run in parallel.
- `dev-docs/networking-packets.md` documents the seam for content authors, plus a known wart in the `IPAddress` ↔ `UInt128` normalization flagged for a follow-up PR.
- The generator was verified on Linux, macOS and Windows under a temporary CI matrix (since removed). It caught two portability bugs — a Windows-only path separator, and a culture-sensitive duration parse that read `2.5` as `25` on comma-decimal locales and *silently* turned a 2.5h cooldown into 25h — plus a third that made the script unparseable on Windows PowerShell 5.1. The source is ASCII-only for that last reason: `#requires` is only honored once a file parses, so non-ASCII in a BOM-less script produces parse errors instead of the version message.

## Tests

**1344 pass** (782 `Server.Tests`, 562 `UOContent.Tests`). New coverage: filter registry (registration, short-circuit, fault-disable), blocklist parsing/CIDR/coalescing, snapshot reload markers, promote-guard TTL, ban-channel fan-out, CrowdSec alert building/dedup/flush-on-stop, and the generator's output-format contract pinned against the reader.
2026-07-25 11:59:37 -07:00
dependabot[bot]
bec4cfa910
chore(deps): bump actions/setup-dotnet from 5 to 6 (#2545)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 16:30:40 -07:00
dependabot[bot]
e4827fc57b
chore(deps): bump actions/upload-artifact from 4 to 7 (#2544)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 16:30:08 -07:00
Kamron Batman
1e97ed50f6
fix: Harden Advanced Search: crash-safety, autosave, correct results & worker fixes (#2543)
## Summary

Hardens the **Advanced Search** engine (`Projects/UOContent/Engines/Advanced Search/`) — the GM entity finder that fans searches across background worker threads. A code review surfaced 14 defects (A–N), including a shard-crasher reachable from a single admin typo and a path that silently disables autosave for the rest of the shard's uptime. Each behavioral fix ships with a test.

Full `UOContent.Tests` suite: **530/530 green** (21 new AdvancedSearch tests).

## Fixes

### Crash / data-loss
- **A — Shard crash on a malformed Property Test.** `AdvancedSearchThreadWorker.Execute` had no `try/catch` and the worker `Thread` is foreground, so a parse throw (`Hits>abc`, `Layer=onehanded` — `Enum.Parse` was case-sensitive, `Hits>1@` — empty sub-expression indexing) terminated the process. Now: `ParseValue`/`CompareValues` use `TryParse`/`Enum.TryParse(ignoreCase)` and return no-match instead of throwing; the per-entity filter is wrapped in `try/catch` (logs + skips); empty expressions are guarded.
- **C — Overlapping searches corrupt state + brick autosave.** `_threadWorkers`/`_threadId` were `static` but `DoSearch` is an instance method; a second search (double-click / two admins) stomped shared worker state and could leave a drain waiting forever on the shared `AutoResetEvent`, so `AutoSave.SavesEnabled` was never restored. Now: an `Interlocked` re-entrancy guard rejects concurrent searches.
- **G — Autosave restore not guaranteed.** The restore lived only in the success callback. Now it's in a `finally` (plus an outer `catch` covering the synchronous setup and a `catch` on the drain body), so autosave + the guard are always released.

### Wrong results
- **D — `@`/`|` operator precedence.** `a@b|c` evaluated as `a && (b || c)` instead of `(a && b) || c`. OR now binds looser than AND (`AdvancedSearchUtilities.EvaluateBoolean`, unit-tested).
- **E — Descending sort, partial last page rendered blank** (the index decreased in descending mode and the `break` early-out killed the loop). Now a bounded `VisibleCount`-driven loop renders the last page in both directions.
- **F — Deleted entities** were not skipped (ghost rows). Now `DoEntitySearch` skips `entity.Deleted`.
- **N — Reference-type comparisons** threw (`Comparer<T>.Default.Compare` on non-`IComparable`) and compared references to a string. Now equality is by value and ordering is guarded to `IComparable` (no throw).

### Worker perf / hardening
- **H** busy-spin → `Thread.Yield()` in the drain; **I** `GetProperties()` cached per `Type`; **J** `HandleValidInternal` moved behind the cheap map/range/region filters; **K** worker threads are `IsBackground` + `Exit()` tolerates an already-terminated worker; **L** `_filter == null` guard; **M** consistent `Volatile` access on `_pause`/`_exit`.

### Documented
- **B** — the residual worker/event-loop read race is documented on `AdvancedSearchThreadWorker`: workers read live entity state concurrently with the loop, so value-type reads may be stale-but-safe and getter exceptions are swallowed; fully eliminating it would require snapshotting entity fields on the main thread (deferred).

## Notes
- New test-only seams (`TryBeginSearch`/`EndSearch`/`IsSearchInProgress`/`VisibleCount`/`TryParseValue`/`EvaluateBoolean`) are `internal` via the existing `InternalsVisibleTo("UOContent.Tests")`.
- Dead `public ParseValue<T>` removed.
- `ConcurrentDictionary` for the reflection cache is intentional — these workers are genuinely parallel.
2026-07-21 07:51:06 -07:00
Kamron Batman
858c1d18bc
fix(opl): only apply the ':#' cliloc marker to integer values (#2540)
`ObjectPropertyList.AppendFormatted<T>(value, format)` treated **any** `{value:#}` as the cliloc marker (emitting `#<value>`). But cliloc numbers are integers — a `float`/`double`/`decimal` formatted with `#` is the standard custom-numeric (`#` = digit placeholder) format, not a cliloc reference, so those were being mis-marked.

Gate the marker on an integer value type:
```csharp
if (format == "#" && value is int or uint or long or ulong or short or ushort or byte or sbyte)
```

Now `{someFloat:#}` formats normally (passes `#` through to `TryFormat`); the marker/standard-format ambiguity narrows to the harmless `{0:#}` **integer** case (`#0`). Existing `AddLocalized(int)` / `{value:#}` (all `int`) are unaffected.

Adds `ObjectPropertyListSpanAddTests.HashFormat_OnlyMarksIntegers`: `int {value:#}` → `#<value>`; `double {value:#}` → `42.0.ToString("#")` (`"42"`, no `#`).
2026-07-19 10:49:16 -07:00
398 changed files with 22159 additions and 5722 deletions

View file

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

View file

@ -40,13 +40,13 @@ jobs:
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.
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v5 uses: actions/setup-dotnet@v6
with: with:
global-json-file: global.json global-json-file: global.json
- name: Install Prerequisites - name: Install Prerequisites
run: | run: |
brew update brew update
brew install icu4c libdeflate zstd argon2 brew install icu4c libdeflate 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
@ -64,7 +64,7 @@ jobs:
fi fi
- name: Upload test results on failure - name: Upload test results on failure
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: TestResults-${{ matrix.name }} name: TestResults-${{ matrix.name }}
path: ./TestResults path: ./TestResults
@ -124,17 +124,41 @@ 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-devel zstd libargon2-devel liburing-devel run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate libargon2 tzdata
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-dev libdeflate-dev zstd libargon2-dev tzdata liburing-dev run: apt-get update -y && apt-get install -y curl '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata
if: ${{ matrix.packageManager == 'apt' }} 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.
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v5 uses: actions/setup-dotnet@v6
with: with:
global-json-file: global.json global-json-file: global.json
- name: Build - name: Build
@ -150,7 +174,7 @@ jobs:
fi fi
- name: Upload test results on failure - name: Upload test results on failure
if: failure() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: TestResults-${{ matrix.name }} name: TestResults-${{ matrix.name }}
path: ./TestResults path: ./TestResults

View file

@ -37,7 +37,7 @@ jobs:
fetch-depth: 0 # Full clone required for Nerdbank.GitVersioning fetch-depth: 0 # Full clone required for Nerdbank.GitVersioning
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v5 uses: actions/setup-dotnet@v6
with: with:
global-json-file: global.json global-json-file: global.json

View file

@ -15,7 +15,7 @@ jobs:
fetch-depth: 0 # avoid shallow clone so nbgv can do its work. fetch-depth: 0 # avoid shallow clone so nbgv can do its work.
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v5 uses: actions/setup-dotnet@v6
with: with:
global-json-file: global.json global-json-file: global.json
- name: Compute version - name: Compute version

21
.gitignore vendored
View file

@ -1,4 +1,5 @@
# Distribution Files # Distribution Files
/Distribution/Data/Files
/Distribution/Logger /Distribution/Logger
/Distribution/Logger.* /Distribution/Logger.*
/Distribution/ModernUO /Distribution/ModernUO
@ -9,16 +10,30 @@
/Distribution/bsdtar /Distribution/bsdtar
/Distribution/Configuration/antimacro.json /Distribution/Configuration/antimacro.json
/Distribution/Configuration/assistants.json /Distribution/Configuration/assistants.json
/Distribution/Configuration/auto-denylist.json
/Distribution/Configuration/bans.json
/Distribution/Configuration/blocklist.json
/Distribution/Configuration/crowdsec.json
/Distribution/Configuration/expansion.json /Distribution/Configuration/expansion.json
/Distribution/Configuration/firewall.json
/Distribution/Configuration/ip-allowlist*.txt
/Distribution/Configuration/ip-allowlist*.txt.tmp
/Distribution/Configuration/ip-blocklist.txt
/Distribution/Configuration/ip-blocklist.txt.tmp
/Distribution/Configuration/login-allowlist.json
/Distribution/Configuration/login-allowlist.txt
/Distribution/Configuration/login-allowlist.txt.tmp
/Distribution/Configuration/modernuo.json /Distribution/Configuration/modernuo.json
/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
/Distribution/Saves /Distribution/Saves
/Distribution/docs /Distribution/docs
/docs/
/Distribution/temp /Distribution/temp
/Distribution/*.dylib /Distribution/*.dylib
/Distribution/*.so /Distribution/*.so
@ -46,5 +61,7 @@
/packages/* /packages/*
/Distribution/Configuration/server-access.json /Distribution/Configuration/server-access.json
# BuildTool native binaries (downloaded from GitHub Releases) # BuildTool native binaries (downloaded from GitHub Releases).
/tools/ # Ignore everything under tools/ except the operator scripts checked in below.
/tools/*
!/tools/Export-IpBlocklist.ps1

View file

@ -12,14 +12,14 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
1. **LINQ** — Tier 1 (zero-cost patterns) free on hot paths; Tier 2 (low overhead) OK on warm paths; Tier 3 (allocating) forbidden on hot paths → `dev-docs/code-standards.md` 1. **LINQ** — Tier 1 (zero-cost patterns) free on hot paths; Tier 2 (low overhead) OK on warm paths; Tier 3 (allocating) forbidden on hot paths → `dev-docs/code-standards.md`
2. **No `Console.WriteLine`** — use `LogFactory.GetLogger(typeof(MyClass))``logger.Information(...)` (requires `using Server.Logging;`) 2. **No `Console.WriteLine`** — use `LogFactory.GetLogger(typeof(MyClass))``logger.Information(...)` (requires `using Server.Logging;`)
3. **No concurrency primitives** — no `lock`, `volatile`, `ConcurrentDictionary`, `Mutex`, etc. Server is single-threaded. 3. **Threading policy** — game logic runs only on the main loop; **never** touch game state (`World`, mobiles, items, maps, timers) from a background thread. Heavy work that *needs* game state must be **chunked** across ticks, not threaded. Heavy work that does *not* need game state (large-file parse, external I/O) **must** run on a background thread **and must yield to world saves** (defer while `World.Saving`/`WorldState.PendingSave`). Publish results back to the loop as an immutable snapshot swapped via a single `volatile` reference — the only sanctioned `volatile`. No `lock`/`Mutex`/`ConcurrentDictionary` in game logic. Rule #10 covers how background work hands results back to the loop → `dev-docs/threading-model.md`
4. **No `World.Mobiles`/`World.Items` iteration** — use spatial queries: `map.GetMobilesInRange<T>()`, `map.GetItemsInRange<T>()` 4. **No `World.Mobiles`/`World.Items` iteration** — use spatial queries: `map.GetMobilesInRange<T>()`, `map.GetItemsInRange<T>()`
5. **Clean up refs in `OnDelete()`/`OnAfterDelete()`** — null out `Item`/`Mobile` references 5. **Clean up refs in `OnDelete()`/`OnAfterDelete()`** — null out `Item`/`Mobile` references
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`). 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` 9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). Setters that coerce/veto/run side effects: use `[SerializableField]` args `allowFieldChange: nameof(BoolRefMethod)` / `fieldChanged: nameof(OldNewMethod)` — reserve `[SerializableProperty]` for custom getters. Serializable `Timer` members declare `[DeserializeTimer(nameof(Method))]` on the field (anchored by default — downtime preserves remaining delay; `wallClock: true` = absolute; method runs only when a timer was running at save). Conditional writes: `[SaveFlag(nameof(Should), nameof(Default))]` on the field. When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` `dev-docs/serialization.md`, `dev-docs/runuo-migration-docs/02-serialization.md`
10. **No `Task.Run`/`new Thread()`** in game code — game logic is single-threaded event loop 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`
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`
@ -28,6 +28,8 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
16. **Prefer switch expressions and switch-when** — use switch expressions for value mapping and switch-when for pattern matching where they improve readability. Exception: skip if unreadable or cold path → `dev-docs/code-standards.md` 16. **Prefer switch expressions and switch-when** — use switch expressions for value mapping and switch-when for pattern matching where they improve readability. Exception: skip if unreadable or cold path → `dev-docs/code-standards.md`
17. **No `System.Text.StringBuilder`** — use `ValueStringBuilder` with `stackalloc` (bounded output) or `ValueStringBuilder.Create()` (unbounded). Supports `$"..."` interpolation directly. Always use `using var` for disposal. Use `Reset()` instead of reassigning → `dev-docs/string-handling.md` 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`
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
@ -44,9 +46,14 @@ 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` |
| Region system | `dev-docs/regions.md` | | Region system | `dev-docs/regions.md` |
| String handling & ValueStringBuilder | `dev-docs/string-handling.md` | | String handling & ValueStringBuilder | `dev-docs/string-handling.md` |
| RunUO migration (overview) | `dev-docs/runuo-migration-docs/00-overview.md` | | RunUO migration (overview) | `dev-docs/runuo-migration-docs/00-overview.md` |
@ -92,7 +99,18 @@ Then copy only the relevant skill files based on the task:
| Migrate persistence (WorldSave) | `migrate-from-runuo/migrate-persistence` | | Migrate 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: `cp dev-docs/claude-skills/<name>.md .claude/skills/` To enable a skill — Claude Code loads `.claude/skills/<name>/SKILL.md`; a bare `.md` dropped
directly into `.claude/skills/` is **not** picked up, and newly installed skills appear in the
*next* session:
```sh
# Standard skills (modernuo-*)
mkdir -p .claude/skills/<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,8 +63,15 @@
<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.3.1" /> <PackageReference Include="Serilog" Version="4.4.0" />
<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,12 +3,16 @@
"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",
@ -28,6 +32,8 @@
"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",
@ -108,6 +114,8 @@
"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",
@ -139,6 +147,8 @@
"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,4 +14,11 @@ 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,6 +17,5 @@
</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,3 +1,4 @@
using System.Runtime.InteropServices;
using BuildTool.Platform; using BuildTool.Platform;
using BuildTool.Publishing; using BuildTool.Publishing;
@ -31,8 +32,10 @@ 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 libicu-dev libdeflate-dev zstd libargon2-dev liburing-dev", "Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata",
"Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel liburing-devel", " (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)",
" (add tzdata-legacy if the shard is configured with an alias such as US/Eastern)",
"Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu tzdata",
"CentOS: Also requires epel-release and CRB enabled" "CentOS: Also requires epel-release and CRB enabled"
] ]
), ),
@ -176,82 +179,59 @@ 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", "liburing-dev" };
var missing = new List<string>(); var missing = new List<string>();
foreach (var package in packages) foreach (var (name, minSoVersion, maxSoVersion) in _linuxLibraries)
{ {
var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}"); var found = CanLoad(name, minSoVersion, maxSoVersion);
var installed = result.Success && result.StandardOutput.Contains("ii");
if (!installed) if (!found)
{ {
missing.Add(package); missing.Add(name);
} }
results.Add(new PrerequisiteResult results.Add(new PrerequisiteResult
{ {
Name = package, Name = name,
Passed = installed, Passed = found,
Details = installed ? "Installed" : "Not installed" Details = found ? "Found" : "Not found"
}); });
} }
if (missing.Count > 0) var hasTimeZoneData = HasTimeZoneData();
if (!hasTimeZoneData)
{ {
missing.Add("tzdata");
}
results.Add(new PrerequisiteResult results.Add(new PrerequisiteResult
{ {
Name = "Install all missing", Name = "tzdata",
Passed = false, Passed = hasTimeZoneData,
IsWarning = true, Details = hasTimeZoneData ? "Found" : "Not found — every zone except UTC will throw"
Details = "Run the following command to install all missing dependencies:",
InstallCommand = $"sudo apt-get install -y {string.Join(' ', missing)}"
}); });
}
if (missing.Count == 0)
{
return results; return results;
} }
private static List<PrerequisiteResult> CheckLinuxDnf(PlatformInfo platform) if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true)
{
var results = new List<PrerequisiteResult>();
var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel", "liburing-devel" };
var missing = new List<string>();
foreach (var package in packages)
{
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"
});
}
// 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
{ {
@ -263,49 +243,131 @@ public static class NativeLibraryChecker
}); });
} }
if (missing.Count > 0)
{
results.Add(new PrerequisiteResult results.Add(new PrerequisiteResult
{ {
Name = "Install all missing", Name = "Install all missing",
Passed = false, Passed = false,
IsWarning = true, IsWarning = true,
Details = "Run the following command to install all missing dependencies:", Details = "Install the missing dependencies. The -dev/-devel packages are not required:",
InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}" InstallCommand = BuildInstallCommand(platform, missing)
}); });
}
return results; return results;
} }
private static List<PrerequisiteResult> CheckLinuxGeneric(PlatformInfo platform) /// <summary>
/// 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()
{ {
var results = new List<PrerequisiteResult>(); try
// Use ldconfig to check for shared libraries
var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p");
var ldOutput = ldResult.Success ? ldResult.StandardOutput : "";
var libraries = new Dictionary<string, string>
{ {
["libicu"] = "libicuuc", return TimeZoneInfo.GetSystemTimeZones().Count > 1;
["libdeflate"] = "libdeflate", }
["zstd"] = "libzstd", catch
["libargon2"] = "libargon2",
["liburing"] = "liburing"
};
foreach (var (name, soName) in libraries)
{ {
var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase); return false;
results.Add(new PrerequisiteResult }
{
Name = name,
Passed = found,
Details = found ? "Found" : "Not found — install using your package manager"
});
} }
return results; /// <summary>
/// Asks the loader directly rather than querying a package database or scanning ldconfig's
/// cache, both of which answer a different question and can disagree with what dlopen will do.
/// Mirrors the binding packages' own probing: the unversioned name first, then libfoo.so.N
/// descending. Bare names go through the full loader search path, so LD_LIBRARY_PATH and
/// /etc/ld.so.conf.d still apply.
/// </summary>
private static bool CanLoad(string library, int minSoVersion, int maxSoVersion)
{
if (TryLoadAndFree($"{library}.so"))
{
return true;
}
for (var soVersion = maxSoVersion; soVersion >= minSoVersion; soVersion--)
{
if (TryLoadAndFree($"{library}.so.{soVersion}"))
{
return true;
}
}
return false;
}
private static bool TryLoadAndFree(string candidate)
{
if (!NativeLibrary.TryLoad(candidate, out var handle))
{
return false;
}
NativeLibrary.Free(handle);
return true;
}
private static string BuildInstallCommand(PlatformInfo platform, List<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,6 +4,7 @@ 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;
@ -36,6 +37,22 @@ 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)
{ {
@ -108,6 +125,12 @@ 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

@ -1,3 +1,4 @@
using System;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Threading; using System.Threading;
@ -78,6 +79,15 @@ internal static class TestServerInitializer
Core.LoopContext = new EventLoopContext(); Core.LoopContext = new EventLoopContext();
Core.Expansion = Expansion.EJ; Core.Expansion = Expansion.EJ;
// Seed the loop clock as Main.cs does before the Configure sweep; otherwise Core.Now is
// DateTime.MinValue for the whole test host.
Core._now = DateTime.UtcNow;
// Timer wheel must exist before NetState.Configure(), which schedules a recurring
// sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs
// before AssemblyHandler.Invoke("Configure")).
Timer.Init(0);
// Configure networking (initializes RingSocketManager for tests) // Configure networking (initializes RingSocketManager for tests)
Server.Network.NetState.Configure(); Server.Network.NetState.Configure();
@ -87,8 +97,6 @@ internal static class TestServerInitializer
// Configure the world // Configure the world
World.Configure(); World.Configure();
Timer.Init(0);
// Load the world // Load the world
World.Load(); World.Load();

View file

@ -5,17 +5,16 @@
<RootNamespace>Server.Tests</RootNamespace> <RootNamespace>Server.Tests</RootNamespace>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<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="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
<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

@ -4,6 +4,7 @@ using Xunit;
namespace Server.Tests.Buffers; namespace Server.Tests.Buffers;
[Collection("Sequential Server Tests")]
public class RawInterpolatedStringHandlerTests public class RawInterpolatedStringHandlerTests
{ {
[Fact] [Fact]

View file

@ -208,6 +208,272 @@ 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

@ -0,0 +1,95 @@
using Xunit;
namespace Server.Tests;
[Collection("Sequential Server Tests")]
public class PlayerConstructedStackingTests
{
// PlayerConstructed is per-instance provenance, and stack operations were written when no
// item carried any. Merging keeps the receiver's copy of a field and splitting rebuilds one
// half from a fixed list of fields, so a flag that is not accounted for in both places is
// one that ordinary stacking can launder or erase.
// Stands in for a real stackable type. LiftItemDupe builds the remainder through the
// parameterless constructor and copies only a fixed list of fields onto it -- Stackable is
// not on that list -- so the remainder is only stackable if the type restores it the way
// every genuine stackable does.
private class StackableItem : Item
{
public StackableItem() => Stackable = true;
public StackableItem(Serial serial) : base(serial) => Stackable = true;
}
private static StackableItem MakeStack(Serial serial, int amount, bool playerConstructed) =>
new(serial) { Amount = amount, PlayerConstructed = playerConstructed };
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CanStackWith_IsTrueWhenProvenanceMatches(bool playerConstructed)
{
var first = MakeStack((Serial)0x1, 5, playerConstructed);
var second = MakeStack((Serial)0x2, 7, playerConstructed);
try
{
Assert.True(first.CanStackWith(second));
}
finally
{
first.Delete();
second.Delete();
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void LiftItemDupe_CopiesPlayerConstructedToRemainder(bool playerConstructed)
{
var stack = MakeStack((Serial)0x1, 10, playerConstructed);
Item remainder = null;
try
{
remainder = Mobile.LiftItemDupe(stack, 4);
Assert.NotNull(remainder);
Assert.NotSame(stack, remainder);
Assert.Equal(4, stack.Amount);
Assert.Equal(6, remainder.Amount);
Assert.Equal(playerConstructed, remainder.PlayerConstructed);
}
finally
{
stack.Delete();
remainder?.Delete();
}
}
[Fact]
public void SplitHalvesRemainStackableWithEachOther()
{
// The two halves of a split must still be one pile's worth: if the split dropped the
// flag, the remainder would no longer stack back onto what it came from.
var stack = MakeStack((Serial)0x1, 10, true);
Item remainder = null;
try
{
remainder = Mobile.LiftItemDupe(stack, 4);
Assert.NotNull(remainder);
Assert.True(stack.CanStackWith(remainder));
Assert.True(stack.StackWith(null, remainder, false));
Assert.Equal(10, stack.Amount);
Assert.True(stack.PlayerConstructed);
}
finally
{
stack.Delete();
remainder?.Delete();
}
}
}

View file

@ -0,0 +1,311 @@
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

@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using Server.Network.Bans;
using Xunit;
namespace Server.Tests.Network.Bans;
public class BanChannelTests
{
private sealed class FakeReporter : IBanReporter
{
public readonly List<(IPAddress ip, TimeSpan ttl, string reason)> Reports = [];
public readonly List<IPAddress> Retractions = [];
public bool ThrowOnReport;
public string Name => "fake";
public bool CanRetract => true;
public void Register() { }
public void Start(CancellationToken token) { }
public void Stop() { }
public void Report(IPAddress address, TimeSpan ttl, string reason)
{
if (ThrowOnReport)
{
throw new InvalidOperationException("boom");
}
Reports.Add((address, ttl, reason));
}
public void Retract(IPAddress address) => Retractions.Add(address);
}
[Fact]
public void Report_FansOutToAllReporters()
{
var a = new FakeReporter();
var b = new FakeReporter();
BanChannel.ConfigureForTesting([a, b]);
BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "rate-limit");
Assert.Single(a.Reports);
Assert.Single(b.Reports);
Assert.Equal("rate-limit", a.Reports[0].reason);
}
[Fact]
public void Report_SwallowsReporterException()
{
var bad = new FakeReporter { ThrowOnReport = true };
var good = new FakeReporter();
BanChannel.ConfigureForTesting([bad, good]);
BanChannel.Report(IPAddress.Parse("1.2.3.4"), TimeSpan.FromHours(1), "manual");
Assert.Single(good.Reports); // the throwing reporter does not block the others
}
[Fact]
public void Report_SuppressedForExemptAddress()
{
var reporter = new FakeReporter();
BanChannel.ConfigureForTesting([reporter]);
var exempt = IPAddress.Parse("203.0.113.7");
BanChannel.IsExempt = (ip, _) => ip.Equals(exempt);
try
{
BanChannel.Report(exempt, TimeSpan.FromHours(1), "rate-limit");
Assert.Empty(reporter.Reports); // escalation suppressed; the local gate already acted
BanChannel.Report(IPAddress.Parse("203.0.113.8"), TimeSpan.FromHours(1), "rate-limit");
Assert.Single(reporter.Reports); // everyone else is still contributed
}
finally
{
BanChannel.IsExempt = null;
}
}
[Fact]
public void Retract_ReachesRetractCapableReporters()
{
var a = new FakeReporter();
BanChannel.ConfigureForTesting([a]);
BanChannel.Retract(IPAddress.Parse("9.9.9.9"));
Assert.Single(a.Retractions);
}
}

View file

@ -0,0 +1,44 @@
using System;
using System.Text.Json;
using Server.Json;
using Server.Network.Bans;
using Xunit;
namespace Server.Tests;
public class BanConfigurationTests
{
// Locks the JsonConfig casing/converter contract: JsonConfig's options are case-SENSITIVE, so
// every settings member must carry an explicit [JsonPropertyName("camelCase")] or it silently
// binds nothing. These tests round-trip through the exact options the loader uses.
[Fact]
public void BanSettings_RoundTripsThroughJsonConfig()
{
var original = new BanSettings
{
ReportRateLimitTrips = false,
AutoBanDuration = TimeSpan.FromHours(2)
};
var json = JsonConfig.Serialize(original);
Assert.Contains("\"reportRateLimitTrips\"", json);
Assert.Contains("\"autoBanDuration\"", json);
var restored = JsonSerializer.Deserialize<BanSettings>(json, JsonConfig.DefaultOptions);
Assert.NotNull(restored);
Assert.Equal(original.ReportRateLimitTrips, restored.ReportRateLimitTrips);
Assert.Equal(original.AutoBanDuration, restored.AutoBanDuration); // TimeSpan survives
}
[Fact]
public void BanSettings_Defaults_AreReportRateLimitTripsFourHourAutoBan()
{
var settings = new BanSettings();
Assert.True(settings.ReportRateLimitTrips);
Assert.Equal(TimeSpan.FromHours(4), settings.AutoBanDuration);
}
}

View file

@ -0,0 +1,133 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: ConnectionFiltersTests.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Net;
using System.Threading;
using Server.Network;
using Xunit;
namespace Server.Tests.Network;
[Collection("Sequential Server Tests")]
public class ConnectionFiltersTests : IDisposable
{
public ConnectionFiltersTests() => ConnectionFilters.ResetForTesting();
public void Dispose() => ConnectionFilters.ResetForTesting();
[Fact]
public void No_filters_denies_nothing()
{
Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy));
Assert.Null(deniedBy);
}
[Fact]
public void Register_is_idempotent_by_name()
{
ConnectionFilters.Register(new FakeFilter("dupe", deny: false));
ConnectionFilters.Register(new FakeFilter("dupe", deny: true));
// The second registration is ignored, so the deny:true instance never gets consulted.
Assert.Single(ConnectionFilters.Filters);
Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _));
}
[Fact]
public void First_denying_filter_short_circuits_and_is_named()
{
var first = new FakeFilter("allow-all", deny: false);
var second = new FakeFilter("deny-all", deny: true);
var third = new FakeFilter("never-reached", deny: true);
ConnectionFilters.Register(first);
ConnectionFilters.Register(second);
ConnectionFilters.Register(third);
Assert.True(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out var deniedBy));
Assert.Equal("deny-all", deniedBy);
Assert.Equal(1, first.Calls);
Assert.Equal(1, second.Calls);
Assert.Equal(0, third.Calls); // short-circuited
}
// A filter that throws once throws for every subsequent connection, which would turn one bug into an
// exception per accept. It must be dropped, and the connection must fail open rather than be denied
// by a filter that never actually answered.
[Fact]
public void Throwing_filter_is_unregistered_and_fails_open()
{
var bad = new FakeFilter("bad", deny: true, throws: true);
var good = new FakeFilter("good", deny: false);
ConnectionFilters.Register(bad);
ConnectionFilters.Register(good);
Assert.False(ConnectionFilters.ShouldDeny(IPAddress.Parse("1.2.3.4"), out _));
Assert.Single(ConnectionFilters.Filters);
Assert.Equal("good", ConnectionFilters.Filters[0].Name);
// Remaining filters still run on the same pass the faulty one was dropped in.
Assert.Equal(1, good.Calls);
}
[Fact]
public void Register_configures_immediately()
{
var filter = new FakeFilter("cfg", deny: false);
ConnectionFilters.Register(filter);
Assert.True(filter.Configured);
}
private sealed class FakeFilter : IConnectionFilter
{
private readonly bool _deny;
private readonly bool _throws;
public FakeFilter(string name, bool deny, bool throws = false)
{
Name = name;
_deny = deny;
_throws = throws;
}
public string Name { get; }
public int Calls { get; private set; }
public bool Configured { get; private set; }
public void Register() => Configured = true;
public void Start(CancellationToken token)
{
}
public void Stop()
{
}
public bool ShouldDeny(IPAddress address)
{
Calls++;
if (_throws)
{
throw new InvalidOperationException("simulated filter bug");
}
return _deny;
}
}
}

View file

@ -0,0 +1,69 @@
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,137 +0,0 @@
using System.Net;
using System.Threading.Tasks;
using Server.Network;
using Xunit;
namespace Server.Tests;
public class FirewallTests
{
[Fact]
public void Firewall_BlocksIPAddress_WhenAdded()
{
var ip = IPAddress.Parse("192.168.1.1");
var entry = new SingleIpFirewallEntry("192.168.1.1");
Assert.False(Firewall.IsBlocked(ip));
Firewall.Add(entry);
Assert.True(Firewall.IsBlocked(ip));
}
[Fact]
public void Firewall_DoesNotBlockIPAddress_WhenNotAdded()
{
var ip = IPAddress.Parse("192.168.1.2");
Assert.False(Firewall.IsBlocked(ip));
}
[Fact]
public void Firewall_StopsBlockingIPAddress_WhenRemoved()
{
var ip = IPAddress.Parse("192.168.1.3");
var entry = new SingleIpFirewallEntry("192.168.1.3");
Firewall.Add(entry);
Assert.True(Firewall.IsBlocked(ip));
Firewall.Remove(entry);
Assert.False(Firewall.IsBlocked(ip));
}
[Fact]
public void Firewall_BlocksIPRange()
{
var entry = new CidrFirewallEntry(IPAddress.Parse("10.0.0.1"), IPAddress.Parse("10.0.0.5"));
Firewall.Add(entry);
Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.1")));
Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.3")));
Assert.True(Firewall.IsBlocked(IPAddress.Parse("10.0.0.5")));
Assert.False(Firewall.IsBlocked(IPAddress.Parse("10.0.0.6")));
}
[Fact]
public void Firewall_CacheInvalidation_WorksOnUpdate()
{
var ip = IPAddress.Parse("192.168.1.10");
var entry = new SingleIpFirewallEntry("192.168.1.10");
Firewall.Add(entry);
Assert.True(Firewall.IsBlocked(ip));
Firewall.Remove(entry);
Assert.False(Firewall.IsBlocked(ip));
}
[Fact]
public void Firewall_ReadsFirewallSetCorrectly()
{
var entry = new SingleIpFirewallEntry("172.16.0.1");
Firewall.Add(entry);
var found = false;
Firewall.ReadFirewallSet(set =>
{
found = set.Contains(entry);
});
Assert.True(found);
}
[Fact]
public void Firewall_IsThreadSafe()
{
var testIps = new IPAddress[256];
for (var i = 0; i <= 255; i++)
{
testIps[i] = IPAddress.Parse($"192.168.0.{i}");
}
var entry = new CidrFirewallEntry(IPAddress.Parse("192.168.0.1"), IPAddress.Parse("192.168.0.255"));
Firewall.Add(entry);
Parallel.ForEach(testIps, ip =>
{
var shouldBlock = int.Parse(ip.ToString().Split('.')[3]) is > 0;
Assert.Equal(shouldBlock, Firewall.IsBlocked(ip));
});
Firewall.Remove(entry);
Parallel.ForEach(testIps, ip =>
{
Assert.False(Firewall.IsBlocked(ip));
});
}
[Fact]
public void Firewall_DoesNotThrowWhenRemovingNonExistentEntry()
{
var entry = new SingleIpFirewallEntry("203.0.113.5");
Assert.False(Firewall.Remove(entry));
}
[Fact]
public void Firewall_CacheHandlesMultipleUpdates()
{
var ip = IPAddress.Parse("192.168.1.20");
var entry = new SingleIpFirewallEntry("192.168.1.20");
Firewall.Add(entry);
Assert.True(Firewall.IsBlocked(ip));
Firewall.Remove(entry);
Assert.False(Firewall.IsBlocked(ip));
Firewall.Add(entry);
Assert.True(Firewall.IsBlocked(ip));
Firewall.Remove(entry);
Assert.False(Firewall.IsBlocked(ip));
}
}

View file

@ -0,0 +1,116 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: ForeignProtocolTests.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Text;
using Server.Network;
using Xunit;
namespace Server.Tests.Network;
public class ForeignProtocolTests
{
private static ForeignProtocolMatch Identify(byte[] bytes, out ForeignProtocolKind kind) =>
ForeignProtocol.Identify(bytes, out kind);
private static byte[] Ascii(string s) => Encoding.ASCII.GetBytes(s);
[Theory]
[InlineData("GET / HTTP/1.1\r\n")]
[InlineData("POST /a HTTP/1.1\r\n")]
[InlineData("HEAD / HTTP/1.0\r\n")]
[InlineData("OPTIONS * HTTP/1.1\r\n")]
[InlineData("CONNECT host:443 HTTP/1.1\r\n")]
[InlineData("DELETE /x HTTP/1.1\r\n")]
public void Http_requests_are_identified(string request)
{
Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(Ascii(request), out var kind));
Assert.Equal(ForeignProtocolKind.Http, kind);
}
[Fact]
public void Ssh_banner_is_identified()
{
Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(Ascii("SSH-2.0-OpenSSH_9.6"), out var kind));
Assert.Equal(ForeignProtocolKind.Ssh, kind);
}
[Fact]
public void Tls_client_hello_is_identified()
{
// handshake, TLS 1.2 record, length 0x0100, ClientHello
byte[] hello = [0x16, 0x03, 0x03, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFC];
Assert.Equal(ForeignProtocolMatch.Confirmed, Identify(hello, out var kind));
Assert.Equal(ForeignProtocolKind.Tls, kind);
}
[Fact]
public void Tls_prefix_without_a_client_hello_is_not_foreign()
{
// A seed can spell 0x16 0x03 0x0? -- the address 22.3.x.x. Byte five is a packet id, not a
// handshake type, so it must fall through to normal parsing.
byte[] seedThenLogin = [0x16, 0x03, 0x03, 0x04, 0x00, 0x80, 0x00, 0x00];
Assert.Equal(ForeignProtocolMatch.None, Identify(seedThenLogin, out var kind));
Assert.Equal(ForeignProtocolKind.None, kind);
}
[Fact]
public void Seed_that_spells_an_http_method_falls_through()
{
// Seed 0x47455420 spells "GET ". The next byte is the 0x80 login packet id, not printable, so this
// is a real client and must not be flagged.
byte[] seedThenLogin = [(byte)'G', (byte)'E', (byte)'T', (byte)' ', 0x80, 0x00, 0x3A, 0x00];
Assert.Equal(ForeignProtocolMatch.None, Identify(seedThenLogin, out _));
}
[Theory]
[InlineData(0x80)] // login request
[InlineData(0x91)] // game server login
[InlineData(0xEF)] // new-style seed packet
public void Ordinary_uo_openings_are_not_foreign(byte secondPacketId)
{
byte[] buffer = [0x7F, 0x00, 0x00, 0x01, secondPacketId, 0x00, 0x00, 0x00];
Assert.Equal(ForeignProtocolMatch.None, Identify(buffer, out _));
}
[Fact]
public void Encrypted_login_is_not_mistaken_for_a_foreign_protocol()
{
// A legitimate client with encryption on when the shard expects none. The login cipher is a
// byte-for-byte XOR, so this is noise of exactly the right length.
byte[] buffer = [0x7F, 0x00, 0x00, 0x01, 0xC3, 0x9A, 0x04, 0xE1, 0x55, 0xB2];
Assert.Equal(ForeignProtocolMatch.None, Identify(buffer, out _));
}
[Fact]
public void Prefix_match_without_enough_bytes_waits()
{
// Framing is never assumed: "GET " split from its request line must wait.
Assert.Equal(ForeignProtocolMatch.Incomplete, Identify(Ascii("GET "), out _));
Assert.Equal(ForeignProtocolMatch.Incomplete, Identify(Ascii("GET /"), out _));
}
[Fact]
public void Too_few_bytes_to_match_a_prefix_is_not_foreign()
{
// Under four bytes the caller's own short-read handling applies.
Assert.Equal(ForeignProtocolMatch.None, Identify(Ascii("GE"), out _));
Assert.Equal(ForeignProtocolMatch.None, Identify([], out _));
}
}

View file

@ -0,0 +1,86 @@
using System;
using Server.Network;
using Xunit;
namespace Server.Tests.Network;
/// <summary>
/// Bounds behaviour of the Huffman compressor when the destination is too small.
///
/// This is reachable in production: NetState only checks that the send buffer has *some* writable
/// space before handing the remainder to Compress, so a nearly-full buffer can offer a span of one
/// to three bytes. The internal guard is computed as an unsigned <c>output.Length - 4</c>, which
/// underflows for those sizes and stops bounding the writes at all.
/// </summary>
public class NetworkCompressionBoundsTests
{
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void RefusesOutputTooSmallToBound(int outputSize)
{
var input = new byte[64];
Array.Fill(input, (byte)'A');
// Sentinel-filled backing array; only the middle window is offered to the compressor, so
// any write past the span shows up as a modified sentinel rather than silent corruption.
var backing = new byte[256];
Array.Fill(backing, (byte)0xCC);
const int windowStart = 64;
var output = backing.AsSpan(windowStart, outputSize);
var written = NetworkCompression.Compress(input, output);
Assert.Equal(0, written);
for (var i = 0; i < backing.Length; i++)
{
Assert.Equal(0xCC, backing[i]);
}
}
[Fact]
public void StillCompressesWhenOutputIsLargeEnough()
{
var input = new byte[64];
Array.Fill(input, (byte)'A');
var output = new byte[256];
var written = NetworkCompression.Compress(input, output);
Assert.True(written > 0);
Assert.True(written <= output.Length);
}
[Fact]
public void ReportsFailureRatherThanOverrunningATightOutput()
{
// Large input against a small-but-bounded output: the guard is well-defined here, so this
// must fail cleanly rather than write past the end.
var input = new byte[4096];
Array.Fill(input, (byte)'A');
var backing = new byte[256];
Array.Fill(backing, (byte)0xCC);
const int windowStart = 64;
const int windowSize = 16;
var output = backing.AsSpan(windowStart, windowSize);
NetworkCompression.Compress(input, output);
for (var i = 0; i < windowStart; i++)
{
Assert.Equal(0xCC, backing[i]);
}
for (var i = windowStart + windowSize; i < backing.Length; i++)
{
Assert.Equal(0xCC, backing[i]);
}
}
}

View file

@ -0,0 +1,145 @@
using System;
using Xunit;
namespace Server.Tests;
/// <summary>
/// The interpolation buffer is rented by the handler ctor and returned by the closing Add, so every
/// hole is evaluated while it is live. A Reset()/Dispose() landing in that window used to leave the
/// next Append* spanning a null array: ArgumentNullException, parameter "array".
/// </summary>
// Sequential: building a list rents from STArrayPool, which is not thread-safe.
[Collection("Sequential Server Tests")]
public class ObjectPropertyListReentrancyTests
{
// Stands in for a property getter that invalidates while its own tooltip is being built.
private static string ResettingHole(ObjectPropertyList list, string value)
{
list.Reset();
return value;
}
private static string DisposingHole(ObjectPropertyList list, string value)
{
list.Dispose();
return value;
}
[Fact]
public void InterpolatedAdd_ResetMidHole_DoesNotThrow()
{
var opl = new ObjectPropertyList(null);
var ex = Record.Exception(
() => opl.Add(1060776, $"{ResettingHole(opl, "Knight")}\t{"Council of Mages"}")
);
Assert.Null(ex);
}
[Fact]
public void InterpolatedAdd_DisposeMidHole_DoesNotThrow()
{
var opl = new ObjectPropertyList(null);
var ex = Record.Exception(
() => opl.Add(1060776, $"{DisposingHole(opl, "Knight")}\t{"Council of Mages"}")
);
Assert.Null(ex);
}
}
/// <summary>
/// The guard is per-list, so nested builds (a GetProperties override that reads another entity's
/// PropertyList) cannot unguard the outer one the way a single shared slot would.
/// </summary>
[Collection("Sequential Server Tests")]
public class ObjectPropertyListNestedBuildTests
{
[Fact]
public void NestedBuild_DoesNotUnguardTheOuterList()
{
var outer = new ObjectPropertyList(null);
var inner = new ObjectPropertyList(null);
outer.IsBuilding = true;
inner.IsBuilding = true; // another entity starts building, and finishes
inner.IsBuilding = false;
Assert.True(outer.IsBuilding);
}
[Fact]
public void Reset_MidInterpolation_LeavesTheListUsable()
{
var opl = new ObjectPropertyList(null);
opl.Add(1060776, $"{Reset(opl, "Knight")}\t{"Council of Mages"}");
opl.Add(1042971, "still working");
opl.Terminate();
Assert.NotNull(opl.Buffer);
}
private static string Reset(ObjectPropertyList list, string value)
{
list.Reset();
return value;
}
}
/// <summary>
/// Invalidating from inside GetProperties is a defect in the getter, not a case to recover from:
/// DEBUG throws, RELEASE keeps a possibly stale tooltip without crashing or leaking.
/// </summary>
[Collection("Sequential Server Tests")]
public class PropertyListInvalidationDuringBuildTests
{
private class SelfInvalidatingMobile : Mobile
{
public int Builds;
public override void GetProperties(IPropertyList list)
{
Builds++;
base.GetProperties(list);
InvalidateProperties();
list.Add(1060776, $"{"Knight"}\t{"Council of Mages"}");
}
}
private static SelfInvalidatingMobile Place(int x)
{
var m = new SelfInvalidatingMobile();
m.MoveToWorld(new Point3D(x, 1000, 0), Map.Felucca);
return m;
}
[Fact]
public void InvalidatingFromGetProperties_FailsLoudlyWithoutTearingDownTheBuild()
{
var wasEnabled = ObjectPropertyList.Enabled;
ObjectPropertyList.Enabled = true;
try
{
var m = Place(1000);
#if DEBUG
Assert.Throws<InvalidOperationException>(() => _ = m.PropertyList);
#else
Assert.Null(Record.Exception(() => _ = m.PropertyList));
#endif
// Refused, not retried.
Assert.Equal(1, m.Builds);
m.Delete();
}
finally
{
ObjectPropertyList.Enabled = wasEnabled;
}
}
}

View file

@ -62,6 +62,21 @@ public class ObjectPropertyListSpanAddTests
Assert.Equal((1070722, "Custom"), entries[0]); Assert.Equal((1070722, "Custom"), entries[0]);
} }
[Fact]
public void HashFormat_OnlyMarksIntegers()
{
// Integer {value:#} emits the cliloc marker "#<value>".
var intList = new ObjectPropertyList(null);
intList.Add(1062028, $"{1043009:#}");
Assert.Equal((1062028, "#1043009"), Decode(intList)[0]);
// Float {value:#} is the standard '#' custom-numeric (digit-placeholder) format, not a cliloc
// marker -- so no leading '#'.
var dblList = new ObjectPropertyList(null);
dblList.Add(1062028, $"{42.0:#}");
Assert.Equal((1062028, 42.0.ToString("#")), Decode(dblList)[0]); // "42"
}
[Fact] [Fact]
public void Add_TruncatesArgumentOverMaxLength() public void Add_TruncatesArgumentOverMaxLength()
{ {

View file

@ -0,0 +1,76 @@
using System;
using Xunit;
namespace Server.Tests;
[Collection("Sequential Server Tests")]
public class AnchoredItemSerializationTests
{
private static byte[] SerializeItem(Item item)
{
var writer = new BufferWriter(new byte[256], true);
item.Serialize(writer);
return writer.Buffer[..(int)writer.Position];
}
/// <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

@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.IO;
using Xunit;
namespace Server.Tests;
public class AnchoredTimeTests
{
private static (BufferWriter Writer, Func<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

@ -0,0 +1,134 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: SortedRangeIndex.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Numerics;
namespace Server.Collections;
/// <summary>
/// Immutable, allocation-lean membership index over a set of inclusive integer ranges. Ranges are
/// stored as two parallel arrays (<c>_mins</c>/<c>_maxs</c>) sorted by minimum and coalesced into
/// disjoint runs, so a single binary search decides membership. Coalescing is required for
/// correctness: <see cref="Contains"/> only inspects the rightmost run whose minimum is &lt;= the
/// value, which is only sound when the runs never overlap or nest.
/// </summary>
public sealed class SortedRangeIndex<T> where T : IBinaryInteger<T>
{
public static readonly SortedRangeIndex<T> Empty = new([], []);
/// <summary>An inclusive <c>[Min, Max]</c> range. Singles are represented as <c>Min == Max</c>.</summary>
public readonly record struct Range(T Min, T Max);
/// <summary>Orders ranges ascending by minimum; the coalescing pass in <see cref="Build"/> requires this.</summary>
public static readonly Comparison<Range> ByMin = static (a, b) => a.Min.CompareTo(b.Min);
private readonly T[] _mins;
private readonly T[] _maxs;
private SortedRangeIndex(T[] mins, T[] maxs)
{
_mins = mins;
_maxs = maxs;
}
public int Count => _mins.Length;
/// <summary>
/// True when <paramref name="value"/> falls in any range. Binary-searches for the rightmost run
/// whose minimum is &lt;= the value, then tests that value against that run's maximum.
/// </summary>
public bool Contains(T value)
{
var lo = 0;
var hi = _mins.Length - 1;
var found = -1;
while (lo <= hi)
{
var mid = (lo + hi) >> 1;
if (_mins[mid] <= value)
{
found = mid;
lo = mid + 1;
}
else
{
hi = mid - 1;
}
}
return found >= 0 && value <= _maxs[found];
}
/// <summary>
/// Builds an index from ranges that are already sorted ascending by <see cref="Range.Min"/> (sort
/// the source with <see cref="ByMin"/> first). Overlapping and nested ranges are merged into disjoint
/// runs. Two passes over the (pooled) input keep the run count exact so only the two final arrays are
/// heap-allocated: pass one counts the runs, pass two fills the exact-size arrays.
/// </summary>
public static SortedRangeIndex<T> Build(ReadOnlySpan<Range> sortedByMin)
{
if (sortedByMin.IsEmpty)
{
return Empty;
}
// Pass 1: count the disjoint runs so the final arrays can be sized exactly.
var runs = 1;
var curMax = sortedByMin[0].Max;
for (var i = 1; i < sortedByMin.Length; i++)
{
var r = sortedByMin[i];
if (r.Min <= curMax)
{
if (r.Max > curMax)
{
curMax = r.Max;
}
}
else
{
runs++;
curMax = r.Max;
}
}
// Pass 2: write the coalesced runs into the exact-size final arrays.
var mins = new T[runs];
var maxs = new T[runs];
var w = 0;
mins[0] = sortedByMin[0].Min;
maxs[0] = sortedByMin[0].Max;
for (var i = 1; i < sortedByMin.Length; i++)
{
var r = sortedByMin[i];
if (r.Min <= maxs[w])
{
if (r.Max > maxs[w])
{
maxs[w] = r.Max;
}
}
else
{
w++;
mins[w] = r.Min;
maxs[w] = r.Max;
}
}
return new SortedRangeIndex<T>(mins, maxs);
}
}

View file

@ -0,0 +1,234 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: EventLoopProfiler.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <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,10 +42,47 @@ public sealed class EventLoopContext : SynchronizationContext
public override SynchronizationContext CreateCopy() => new EventLoopContext(); public override SynchronizationContext CreateCopy() => new EventLoopContext();
public void Post(Action d, Priority priority = Priority.Normal) => /// <summary>
(priority == Priority.High ? _priorityQueue : _queue).Enqueue(d); /// 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 override void Post(SendOrPostCallback d, object state) => _queue.Enqueue(() => d(state)); public void Post(Action d, Priority priority = Priority.Normal)
{
(priority == Priority.High ? _priorityQueue : _queue).Enqueue(d);
WakeEventLoop();
}
public override void Post(SendOrPostCallback d, object state)
{
_queue.Enqueue(() => d(state));
WakeEventLoop();
}
/// <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)
{ {
@ -63,6 +100,8 @@ public sealed class EventLoopContext : SynchronizationContext
evt.Set(); evt.Set();
}); });
WakeEventLoop();
evt.WaitOne(); evt.WaitOne();
} }

View file

@ -37,6 +37,13 @@ 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

@ -10,4 +10,5 @@ public static class ServerFeatureFlags
public static bool PvPCombat { get; set; } = true; public static bool PvPCombat { get; set; } = true;
public static bool BankAccess { get; set; } = true; public static bool BankAccess { get; set; } = true;
public static bool SpeedhackDetection { get; set; } public static bool SpeedhackDetection { get; set; }
public static bool InsuranceEnabled { get; set; }
} }

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,6 +84,7 @@ 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
{ {
@ -96,14 +97,13 @@ 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,14 +115,13 @@ 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
{ {
@ -134,10 +133,8 @@ 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)
{ {
// Item was moved (SetLastMoved called) - re-bucket or move to overflow // Deadline was pushed out (SetLastMoved/RestartDecay) - re-bucket or move to overflow
if (timeUntilDecay > _totalBucketSpan) 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.SetLastMoved(); item.RestartDecay();
} }
} }
} }

View file

@ -14,6 +14,7 @@
*************************************************************************/ *************************************************************************/
using System; using System;
using System.Diagnostics;
using System.Collections.Generic; using System.Collections.Generic;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
@ -334,7 +335,25 @@ 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;
public DateTime LastMoved { get; set; } private DateTime _lastMoved;
public DateTime LastMoved
{
get => _lastMoved;
set
{
_lastMoved = value;
// A move at or past the reset stamp supersedes it; drop it so the CompactInfo can collapse.
var info = LookupCompactInfo();
if (info != null && info.m_DecayReset != default && info.m_DecayReset <= value)
{
info.m_DecayReset = default;
VerifyCompactInfo();
}
}
}
[CommandProperty(AccessLevel.GameMaster)] [CommandProperty(AccessLevel.GameMaster)]
public bool Stackable public bool Stackable
@ -372,7 +391,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
} }
Delta(ItemDelta.Update); Delta(ItemDelta.Update);
UpdateDecayRegistration(); RestartDecay();
} }
} }
} }
@ -388,7 +407,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
SetFlag(ImplFlag.Movable, value); SetFlag(ImplFlag.Movable, value);
Delta(ItemDelta.Update); Delta(ItemDelta.Update);
UpdateDecayRegistration(); RestartDecay();
} }
} }
} }
@ -748,6 +767,12 @@ 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
{ {
@ -798,7 +823,22 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public virtual int HuedItemID => m_ItemID; public virtual int HuedItemID => m_ItemID;
public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this)); public ObjectPropertyList PropertyList
{
get
{
if (m_PropertyList == null)
{
// Publish the list before building it so a nested InvalidateProperties can see the
// build in progress and defer instead of recursing into a second throwaway list.
var list = new ObjectPropertyList(this);
m_PropertyList = list;
InitializePropertyList(list);
}
return m_PropertyList;
}
}
/// <summary> /// <summary>
/// Overridable. Fills an <see cref="ObjectPropertyList" /> with everything applicable. By default, this invokes /// Overridable. Fills an <see cref="ObjectPropertyList" /> with everything applicable. By default, this invokes
@ -823,7 +863,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
public virtual void Serialize(IGenericWriter writer) public virtual void Serialize(IGenericWriter writer)
{ {
writer.Write(9); // version writer.Write(11); // version
var flags = SaveFlag.None; var flags = SaveFlag.None;
@ -933,6 +973,11 @@ 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)
@ -963,16 +1008,21 @@ 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);
/* begin last moved time optimization */ // Anchored: shifted by downtime at load, so time-since-moved is preserved and the
var ticks = LastMoved.Ticks; // bytes are stable across saves while the item does not move.
var now = Core.Now.Ticks; writer.WriteAnchoredTime(LastMoved);
var minutes = new TimeSpan(now - ticks).TotalMinutes; if (GetSaveFlag(flags, SaveFlag.DecayReset))
{
writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue)); writer.WriteAnchoredTime(info.m_DecayReset);
/* end */ }
if (GetSaveFlag(flags, SaveFlag.Direction)) if (GetSaveFlag(flags, SaveFlag.Direction))
{ {
@ -1291,6 +1341,12 @@ 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();
@ -1521,7 +1577,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
if (oldValue != value) if (oldValue != value)
{ {
UpdateDecayRegistration(); RestartDecay();
} }
} }
} }
@ -1715,6 +1771,7 @@ 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;
@ -1884,7 +1941,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
{ {
list.Add(1049643); // cursed list.Add(1049643); // cursed
} }
else if (Insured) else if (ServerFeatureFlags.InsuranceEnabled && Insured)
{ {
list.Add(1061682); // <b>insured</b> list.Add(1061682); // <b>insured</b>
} }
@ -2299,7 +2356,64 @@ 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 => LastMoved + DecayTime; public DateTime ScheduledDecayTime
{
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()
{ {
@ -2309,6 +2423,12 @@ 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()
@ -2341,6 +2461,11 @@ 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)
@ -2428,10 +2553,20 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
} }
private ObjectPropertyList InitializePropertyList(ObjectPropertyList list) private ObjectPropertyList InitializePropertyList(ObjectPropertyList list)
{
list.IsBuilding = true;
try
{ {
GetProperties(list); GetProperties(list);
AppendChildProperties(list); AppendChildProperties(list);
list.Terminate(); list.Terminate();
}
finally
{
list.IsBuilding = false;
}
return list; return list;
} }
@ -2448,6 +2583,26 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
return; return;
} }
// Always a bug in the property getter, and there is no correct recovery: refuse rather than
// hide it. RELEASE keeps a possibly stale tooltip, DEBUG throws.
// See dev-docs/property-lists.md "Never Invalidate From Inside GetProperties".
if (m_PropertyList?.IsBuilding == true)
{
logger.Error(
"{Entity} called InvalidateProperties() while its property list was being built. Remove the side effect from the property getter, or defer it with Timer.DelayCall.\n{StackTrace}",
this,
new StackTrace()
);
#if DEBUG
throw new InvalidOperationException(
$"{this} invalidated its property list from inside GetProperties. Remove the side effect from the property getter."
);
#else
return;
#endif
}
if (m_Map != null && m_Map != Map.Internal && !World.Loading) if (m_Map != null && m_Map != Map.Internal && !World.Loading)
{ {
int? oldHash; int? oldHash;
@ -2611,6 +2766,8 @@ 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:
@ -2618,7 +2775,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
{ {
var flags = (SaveFlag)reader.ReadInt(); var flags = (SaveFlag)reader.ReadInt();
if (version < 7) if (version >= 11)
{
LastMoved = reader.ReadAnchoredTime();
}
else if (version < 7)
{ {
LastMoved = reader.ReadDeltaTime(); LastMoved = reader.ReadDeltaTime();
} }
@ -2636,6 +2797,18 @@ 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();
@ -2808,6 +2981,8 @@ 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);
@ -3279,6 +3454,12 @@ 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;
@ -3385,7 +3566,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(typeof(IgnoreDupeAttribute), true) != null || !p.CanRead || !p.CanWrite) if (p.GetCustomAttribute<IgnoreDupeAttribute>(true) != null || !p.CanRead || !p.CanWrite)
{ {
continue; continue;
} }
@ -4216,12 +4397,12 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
} }
public virtual bool CheckBlessed(Mobile m) => public virtual bool CheckBlessed(Mobile m) =>
m_LootType == LootType.Blessed || Mobile.InsuranceEnabled && Insured || m != null && m == BlessedFor; m_LootType == LootType.Blessed || ServerFeatureFlags.InsuranceEnabled && Insured || m != null && m == BlessedFor;
public virtual bool CheckNewbied() => m_LootType == LootType.Newbied; public virtual bool CheckNewbied() => m_LootType == LootType.Newbied;
public virtual bool IsStandardLoot() => public virtual bool IsStandardLoot() =>
(!Mobile.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular; (!ServerFeatureFlags.InsuranceEnabled || !Insured) && BlessedFor == null && m_LootType == LootType.Regular;
public override string ToString() => $"{Serial} \"{GetType().Name}\""; public override string ToString() => $"{Serial} \"{GetType().Name}\"";
@ -4291,6 +4472,8 @@ 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;
@ -4328,6 +4511,8 @@ 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

@ -1,4 +1,4 @@
/************************************************************************* /*************************************************************************
* ModernUO * * ModernUO *
* Copyright 2019-2026 - ModernUO Development Team * * Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com * * Email: hi@modernuo.com *
@ -30,6 +30,7 @@ using Server.Compression;
using Server.Json; using Server.Json;
using Server.Logging; using Server.Logging;
using Server.Network; using Server.Network;
using Server.Network.Bans;
using Server.Text; using Server.Text;
namespace Server; namespace Server;
@ -38,10 +39,181 @@ public static class Core
{ {
private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core)); private static readonly ILogger logger = LogFactory.GetLogger(typeof(Core));
private static bool _performProcessKill; // Written off-loop (Kill, RequestSnapshot); volatile because the loop blocks between reads.
private static volatile bool _performProcessKill;
private static bool _restartOnKill; private static bool _restartOnKill;
private static bool _performSnapshot; private static volatile 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;
@ -110,14 +282,6 @@ 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
@ -234,6 +398,10 @@ 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)
@ -260,7 +428,7 @@ public static class Core
// ignored // ignored
} }
if (!close && !Core.Headless) if (!close && !Headless)
{ {
Console.WriteLine("This exception is fatal, press return to exit"); Console.WriteLine("This exception is fatal, press return to exit");
ConsoleInputHandler.ReadLine(); ConsoleInputHandler.ReadLine();
@ -342,6 +510,8 @@ public static class Core
World.ExitSerializationThreads(); World.ExitSerializationThreads();
PingServer.Shutdown(); PingServer.Shutdown();
NetState.Shutdown(); NetState.Shutdown();
BanChannel.Stop();
ConnectionFilters.Stop();
if (!_crashed) if (!_crashed)
{ {
@ -421,6 +591,45 @@ 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
@ -437,10 +646,8 @@ public static class Core
AssemblyHandler.LoadAssemblies(assemblyFiles); AssemblyHandler.LoadAssemblies(assemblyFiles);
// First-boot interactive setup. Runs after assemblies are loaded (so content can // First-boot interactive setup. After assemblies load so content can register prompts,
// register prompts) but before any Serilog output, so console prompts are not // before any Serilog output so prompts are not interleaved with the async console sink.
// 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);
@ -450,6 +657,11 @@ 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");
@ -461,44 +673,76 @@ public static class Core
AssemblyHandler.Invoke("Initialize"); AssemblyHandler.Invoke("Initialize");
BanChannel.Start(ClosingTokenSource.Token);
ConnectionFilters.Start(ClosingTokenSource.Token);
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;
} }
@ -508,29 +752,35 @@ public static class Core
break; break;
} }
if (sample++ == interval) CheckSchedulerHealth();
{
sample = 0;
var nowRaw = Stopwatch.GetTimestamp();
_currentCPS = frequency / (nowRaw - lastRaw); if (_eventLoopIdleWaitMs > 0 && _tickCount - _idleSleepSuspendedUntil >= 0 && IsIdle())
if (!_cpsInitialized)
{ {
_averageCPS = _currentCPS; // Re-read the clock: a stale timestamp overstates the time to the next tick
_cpsInitialized = true; // and sleeps straight past it.
var start = GetTimestamp();
var due = Timer.MillisecondsUntilNextTick(start);
if (due > 0)
{
var requested = (int)Math.Min(due, _eventLoopIdleWaitMs);
// The GC prefers to collect during idle sleeps, so its pauses land here by
// design and are not the host's fault. Gen1 and above (what
// CollectionCount(1) counts) are the only pauses long enough to reach a tick.
var collections = GC.CollectionCount(1);
NetState.WaitForCompletion(requested);
var elapsed = GetTimestamp() - start;
EventLoopProfiler.SleepEnd(requested, elapsed);
_sleepAttempts++;
// The second collection read sits behind the overshoot test, so the common
// path reads the counter once, not twice.
if (elapsed - requested >= Timer.TickRate && GC.CollectionCount(1) == collections)
{
_lateWakes++;
} }
else
{
_averageCPS += alpha * (_currentCPS - _averageCPS);
}
lastRaw = nowRaw;
var sleepMs = (int)Timer.MillisecondsUntilNextTick(_tickCount);
if (sleepMs >= 2)
{
NetState.WaitForCompletion(sleepMs - 1);
} }
} }
} }
@ -548,6 +798,9 @@ 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

@ -26,6 +26,7 @@ using Server.Network;
using Server.Prompts; using Server.Prompts;
using Server.Targeting; using Server.Targeting;
using System; using System;
using System.Diagnostics;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Server.Buffers; using Server.Buffers;
@ -41,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 public class DamageEntry : IValueLinkListNode<DamageEntry>
{ {
public DamageEntry(Mobile damager) => Damager = damager; public DamageEntry(Mobile damager) => Damager = damager;
@ -56,6 +57,11 @@ public class 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]
@ -376,7 +382,6 @@ 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
@ -953,13 +958,27 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public static TimeSpan AutoManifestTimeout { get; set; } = TimeSpan.FromSeconds(5.0); public static TimeSpan AutoManifestTimeout { get; set; } = TimeSpan.FromSeconds(5.0);
public static bool InsuranceEnabled { get; set; }
public static int ActionDelay { get; set; } = 500; public static int ActionDelay { get; set; } = 500;
public static VisibleDamageType VisibleDamageType { get; set; } public static VisibleDamageType VisibleDamageType { get; set; }
public List<DamageEntry> DamageEntries { get; private set; } private ValueLinkList<DamageEntry> _damageEntries;
/// <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; }
@ -1628,7 +1647,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; }
@ -2021,10 +2040,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
Aggressors[i].CanReportMurder = false; Aggressors[i].CanReportMurder = false;
} }
if (DamageEntries.Count > 0) ClearDamageEntries(); // reset damage entries on full HP
{
DamageEntries.Clear(); // reset damage entries on full HP
}
} }
else if (CanRegenHits) else if (CanRegenHits)
{ {
@ -2293,7 +2309,22 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public int CompareTo(Mobile other) => other == null ? -1 : Serial.CompareTo(other.Serial); public int CompareTo(Mobile other) => other == null ? -1 : Serial.CompareTo(other.Serial);
public virtual int HuedItemID => m_Female ? 0x2107 : 0x2106; public virtual int HuedItemID => m_Female ? 0x2107 : 0x2106;
public ObjectPropertyList PropertyList => m_PropertyList ??= InitializePropertyList(new ObjectPropertyList(this)); public ObjectPropertyList PropertyList
{
get
{
if (m_PropertyList == null)
{
// Publish the list before building it so a nested InvalidateProperties can see the
// build in progress and defer instead of recursing into a second throwaway list.
var list = new ObjectPropertyList(this);
m_PropertyList = list;
InitializePropertyList(list);
}
return m_PropertyList;
}
}
public virtual void GetProperties(IPropertyList list) public virtual void GetProperties(IPropertyList list)
{ {
@ -2310,11 +2341,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public virtual void Serialize(IGenericWriter writer) public virtual void Serialize(IGenericWriter writer)
{ {
writer.Write(37); // version writer.Write(38); // version
writer.WriteDeltaTime(LastStrGain); writer.WriteAnchoredTime(LastStrGain);
writer.WriteDeltaTime(LastIntGain); writer.WriteAnchoredTime(LastIntGain);
writer.WriteDeltaTime(LastDexGain); writer.WriteAnchoredTime(LastDexGain);
byte hairflag = 0x00; byte hairflag = 0x00;
@ -5234,8 +5265,15 @@ 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;
// 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; item.Map = oldItem.Map;
}
oldItem.OnAfterDuped(item); oldItem.OnAfterDuped(item);
@ -5724,24 +5762,54 @@ 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)
{ {
for (var i = DamageEntries.Count - 1; i >= 0; --i) PruneExpiredDamageEntries();
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i]; for (var de = _damageEntries._last; de != null; de = de.Previous)
if (de.HasExpired)
{ {
DamageEntries.RemoveAt(i); if (allowSelf || de.Damager != this)
}
else if (allowSelf || de.Damager != this)
{ {
return de; return de;
} }
@ -5754,21 +5822,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastRecentDamageEntry(bool allowSelf) public DamageEntry FindLeastRecentDamageEntry(bool allowSelf)
{ {
for (var i = 0; i < DamageEntries.Count; ++i) PruneExpiredDamageEntries();
{
if (i < 0)
{
continue;
}
var de = DamageEntries[i]; for (var de = _damageEntries._first; de != null; de = de.Next)
if (de.HasExpired)
{ {
DamageEntries.RemoveAt(i); if (allowSelf || de.Damager != this)
--i;
}
else if (allowSelf || de.Damager != this)
{ {
return de; return de;
} }
@ -5779,24 +5837,17 @@ 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 i = DamageEntries.Count - 1; i >= 0; --i) for (var de = _damageEntries._last; de != null; de = de.Previous)
{ {
if (i >= DamageEntries.Count) if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven > mostTotal.DamageGiven))
{
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;
} }
@ -5809,46 +5860,28 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
public DamageEntry FindLeastTotalDamageEntry(bool allowSelf) public DamageEntry FindLeastTotalDamageEntry(bool allowSelf)
{ {
DamageEntry mostTotal = null; PruneExpiredDamageEntries();
for (var i = DamageEntries.Count - 1; i >= 0; --i) DamageEntry leastTotal = null;
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i]; for (var de = _damageEntries._last; de != null; de = de.Previous)
if (de.HasExpired)
{ {
DamageEntries.RemoveAt(i); if ((allowSelf || de.Damager != this) && (leastTotal == null || de.DamageGiven < leastTotal.DamageGiven))
}
else if ((allowSelf || de.Damager != this) && (mostTotal == null || de.DamageGiven < mostTotal.DamageGiven))
{ {
mostTotal = de; leastTotal = de;
} }
} }
return mostTotal; return leastTotal;
} }
public DamageEntry FindDamageEntryFor(Mobile m) public DamageEntry FindDamageEntryFor(Mobile m)
{ {
for (var i = DamageEntries.Count - 1; i >= 0; --i) PruneExpiredDamageEntries();
{
if (i >= DamageEntries.Count)
{
continue;
}
var de = DamageEntries[i]; for (var de = _damageEntries._last; de != null; de = de.Previous)
if (de.HasExpired)
{ {
DamageEntries.RemoveAt(i); if (de.Damager == m)
}
else if (de.Damager == m)
{ {
return de; return de;
} }
@ -5866,8 +5899,13 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
de.DamageGiven += amount; de.DamageGiven += amount;
de.LastDamage = Core.Now; de.LastDamage = Core.Now;
DamageEntries.Remove(de); // Move to the tail so the list stays in LastDamage order.
DamageEntries.Add(de); if (de.OnLinkList)
{
_damageEntries.Remove(de);
}
_damageEntries.AddLast(de);
var master = from.GetDamageMaster(this); var master = from.GetDamageMaster(this);
@ -6129,6 +6167,7 @@ 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
@ -6136,10 +6175,19 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
case 33: // Removed created case 33: // Removed created
case 32: // Removed StuckMenu case 32: // Removed StuckMenu
case 31: case 31:
{
if (version >= 38)
{
LastStrGain = reader.ReadAnchoredTime();
LastIntGain = reader.ReadAnchoredTime();
LastDexGain = reader.ReadAnchoredTime();
}
else
{ {
LastStrGain = reader.ReadDeltaTime(); LastStrGain = reader.ReadDeltaTime();
LastIntGain = reader.ReadDeltaTime(); LastIntGain = reader.ReadDeltaTime();
LastDexGain = reader.ReadDeltaTime(); LastDexGain = reader.ReadDeltaTime();
}
goto case 30; goto case 30;
} }
@ -6447,9 +6495,6 @@ 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())
@ -7226,9 +7271,19 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
} }
private ObjectPropertyList InitializePropertyList(ObjectPropertyList list) private ObjectPropertyList InitializePropertyList(ObjectPropertyList list)
{
list.IsBuilding = true;
try
{ {
GetProperties(list); GetProperties(list);
list.Terminate(); list.Terminate();
}
finally
{
list.IsBuilding = false;
}
return list; return list;
} }
@ -7245,6 +7300,26 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
return; return;
} }
// Always a bug in the property getter, and there is no correct recovery: refuse rather than
// hide it. RELEASE keeps a possibly stale tooltip, DEBUG throws.
// See dev-docs/property-lists.md "Never Invalidate From Inside GetProperties".
if (m_PropertyList?.IsBuilding == true)
{
logger.Error(
"{Entity} called InvalidateProperties() while its property list was being built. Remove the side effect from the property getter, or defer it with Timer.DelayCall.\n{StackTrace}",
this,
new StackTrace()
);
#if DEBUG
throw new InvalidOperationException(
$"{this} invalidated its property list from inside GetProperties. Remove the side effect from the property getter."
);
#else
return;
#endif
}
if (m_Map != null && m_Map != Map.Internal && !World.Loading) if (m_Map != null && m_Map != Map.Internal && !World.Loading)
{ {
int? oldHash; int? oldHash;
@ -7752,13 +7827,10 @@ 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;
} }
@ -7790,6 +7862,12 @@ 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,17 +21,15 @@ namespace Server;
[SerializationGenerator(0)] [SerializationGenerator(0)]
public partial class ResistanceMod : MobileMod public partial class ResistanceMod : MobileMod
{ {
[SerializableField(0)] [SerializableField(0, fieldChanged: nameof(OnTypeChanged))]
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)] [SerializableField(1, fieldChanged: nameof(OnOffsetChanged))]
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,33 +21,29 @@ namespace Server;
[SerializationGenerator(0)] [SerializationGenerator(0)]
public abstract partial class SkillMod : MobileMod public abstract partial class SkillMod : MobileMod
{ {
[SerializableField(0)] [SerializableField(0, fieldChanged: nameof(OnObeyCapChanged))]
private bool _obeyCap; private bool _obeyCap;
[SerializableFieldChanged(0)]
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void OnObeCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update(); private void OnObeyCapChanged(bool oldValue, bool newValue) => Owner?.Skills[_skill]?.Update();
[SerializableField(1)] [SerializableField(1, fieldChanged: nameof(OnSkillChanged))]
private SkillName _skill; 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)] [SerializableField(2, fieldChanged: nameof(OnRelativeChanged))]
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)] [SerializableField(3, fieldChanged: nameof(OnValueChanged))]
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

@ -0,0 +1,160 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BanChannel.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using Server.Logging;
namespace Server.Network.Bans;
/// <summary>
/// Coordinates the configured <see cref="IBanReporter"/> contribution sinks. Enforcement is NOT here —
/// the accept path asks <see cref="ConnectionFilters"/>. This channel only fans locally-decided bans out
/// to external systems (CrowdSec), which distribute them to OS-level bouncers.
/// </summary>
public static class BanChannel
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BanChannel));
private static IBanReporter[] _reporters = [];
public static IReadOnlyList<IBanReporter> Reporters => _reporters;
/// <summary>
/// Registers a contribution sink from content (inversion of control). Idempotent by
/// <see cref="IBanReporter.Name"/>: a second registration of the same name is ignored. Configures the
/// reporter immediately so it is ready before <see cref="Start"/>.
/// </summary>
public static void Register(IBanReporter reporter)
{
if (reporter == null)
{
return;
}
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
if (reporters[i].Name == reporter.Name)
{
return;
}
}
reporter.Register();
var updated = new IBanReporter[_reporters.Length + 1];
Array.Copy(_reporters, updated, _reporters.Length);
updated[^1] = reporter;
_reporters = updated;
logger.Information("Ban channel registered reporter '{Name}'", reporter.Name);
}
internal static void ConfigureForTesting(IBanReporter[] reporters) => _reporters = reporters ?? [];
public static void Start(CancellationToken token)
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
var reporter = reporters[i];
try
{
reporter.Start(token);
}
catch (Exception e)
{
// A broken contribution path must not crash boot — enforcement is local and unaffected.
logger.Error(e, "Ban reporter '{Name}' failed to start; continuing without it", reporter.Name);
}
}
}
public static void Stop()
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
var reporter = reporters[i];
try
{
reporter.Stop();
}
catch (Exception e)
{
logger.Warning(e, "Ban reporter '{Name}' threw while stopping", reporter.Name);
}
}
}
/// <summary>Fans a locally-decided ban out to every reporter. Non-blocking; never throws.</summary>
/// <summary>
/// Optional content-supplied exemption; true drops the contribution before any reporter sees it. This
/// withholds escalation only — the gate that reached the verdict has already acted.
/// </summary>
/// <remarks>
/// An implementation that ignores <c>reason</c> would silently swallow manual bans. See
/// <see cref="BanReasons.IsBehavioral"/>.
/// </remarks>
public static Func<IPAddress, string, bool> IsExempt { get; set; }
public static void Report(IPAddress ip, TimeSpan ttl, string reason)
{
var exempt = IsExempt;
if (exempt != null && exempt(ip, reason))
{
logger.Debug("{Address} not contributed ('{Reason}'): exempt", ip, reason);
return;
}
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
try
{
reporters[i].Report(ip, ttl, reason);
}
catch (Exception e)
{
logger.Warning(e, "Ban reporter '{Name}' threw during Report", reporters[i].Name);
}
}
}
/// <summary>Fans a retraction (manual unban) out to every retract-capable reporter.</summary>
public static void Retract(IPAddress ip)
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
if (!reporters[i].CanRetract)
{
continue;
}
try
{
reporters[i].Retract(ip);
}
catch (Exception e)
{
logger.Warning(e, "Ban reporter '{Name}' threw during Retract", reporters[i].Name);
}
}
}
}

View file

@ -0,0 +1,98 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BanConfiguration.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.IO;
using System.Text.Json.Serialization;
using Server.Json;
namespace Server.Network.Bans;
/// <summary>
/// Loads the <see cref="BanSettings"/> from <c>Configuration/bans.json</c> (matching the per-feature
/// JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file writes a
/// local-only, fail-open template so operators have something to edit.
/// </summary>
public static class BanConfiguration
{
private const string _path = "Configuration/bans.json";
private static bool _loaded;
/// <summary>
/// Never null: the accept and reap paths read this per connection, including before <see cref="Configure"/>
/// has run (a harness driving <c>NetState.Slice</c> directly), so it starts at the record's defaults.
/// </summary>
public static BanSettings Settings { get; private set; } = new();
public static void Configure()
{
// Idempotent; flagged rather than null-checked because Settings is non-null from the start.
if (_loaded)
{
return;
}
_loaded = true;
var path = Path.Join(Core.BaseDirectory, _path);
if (File.Exists(path))
{
Settings = JsonConfig.Deserialize<BanSettings>(path);
}
else
{
Settings = new BanSettings
{
ReportRateLimitTrips = true,
AutoBanDuration = TimeSpan.FromHours(4)
};
Save();
}
}
private static void Save()
{
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
}
}
/// <summary>Ban-channel policy: which reporters receive contributions, and how auto-detections are handled.</summary>
public record BanSettings
{
/// <summary>Whether IP rate-limiter trips are contributed to reporters. They never enter the local firewall set.</summary>
[JsonPropertyName("reportRateLimitTrips")]
public bool ReportRateLimitTrips { get; set; } = true;
/// <summary>Duration reported for an auto-detected (rate-limit) ban.</summary>
[JsonPropertyName("autoBanDuration")]
public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4);
/// <summary>
/// Whether behavioural detections are contributed to reporters. Those connections are disconnected either
/// way; this only controls escalation.
/// </summary>
/// <remarks>
/// Keyed on bytes-received, never elapsed time: a connection that sent something and ran out of time is
/// far more likely a slow link than an attack. See <c>dev-docs/ip-bans-and-allowlists.md</c>.
/// </remarks>
[JsonPropertyName("reportBadConnects")]
public bool ReportBadConnects { get; set; } = true;
[JsonPropertyName("badConnectDuration")]
public TimeSpan BadConnectDuration { get; set; } = TimeSpan.FromHours(4);
}

View file

@ -0,0 +1,48 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BanReasons.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
namespace Server.Network.Bans;
/// <summary>The <c>reason</c> slugs contributed through <see cref="BanChannel"/>. Policy keys off these.</summary>
public static class BanReasons
{
/// <summary>An operator banned this address explicitly. Never exempt, never auto-denied.</summary>
public const string Manual = "manual";
public const string RateLimit = "rate-limit";
/// <summary>Matched the reputation blocklist. Enforced by its own filter, not by behaviour.</summary>
public const string Blocklist = "blocklist";
/// <summary>Reaped without ever sending a byte.</summary>
public const string SilentConnect = "silent-connect";
/// <summary>Opened with a zero seed, which no real client sends.</summary>
public const string InvalidSeed = "invalid-seed";
/// <summary>Positively identified as another protocol entirely. See <see cref="ForeignProtocol"/>.</summary>
public const string ForeignProtocol = "foreign-protocol";
/// <summary>
/// Verdicts the shard reached by watching the connection. Only these may be exempted, and only these feed
/// the local denylist.
/// </summary>
/// <remarks>
/// Opt-in rather than "everything except <see cref="Manual"/>", so a reason added later escalates normally
/// instead of silently inheriting an exemption.
/// </remarks>
public static bool IsBehavioral(string reason) =>
reason is RateLimit or SilentConnect or InvalidSeed or ForeignProtocol;
}

View file

@ -0,0 +1,55 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IBanReporter.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Net;
using System.Threading;
namespace Server.Network.Bans;
/// <summary>
/// A contribution sink behind <see cref="BanChannel"/>. Reporters receive locally-decided bans
/// (manual admin bans, rate-limit trips, blocklist promotions) and forward them to an external system
/// (e.g. CrowdSec), which distributes them to OS-level bouncers. Reporters never answer the accept-path
/// membership query — that is an <see cref="IConnectionFilter"/>'s job.
/// </summary>
public interface IBanReporter
{
/// <summary>Stable id for logging/config (e.g. <c>crowdsec</c>).</summary>
string Name { get; }
/// <summary>Reads configuration. No network or file I/O here.</summary>
void Register();
/// <summary>Starts background delivery. The token is cancelled on shutdown.</summary>
void Start(CancellationToken token);
/// <summary>Flushes and tears down background delivery.</summary>
void Stop();
/// <summary>
/// Enqueues a ban contribution. MUST be non-blocking and safe on the accept path: it may only
/// enqueue (bounded, drop-on-overflow) and never perform synchronous I/O.
/// </summary>
/// <param name="ttl"><see cref="TimeSpan.Zero"/> or negative = use the reporter's default duration.</param>
/// <param name="reason">Short slug (<c>manual</c>, <c>rate-limit</c>) used as the scenario suffix.</param>
void Report(IPAddress address, TimeSpan ttl, string reason);
/// <summary>True if this reporter can retract a previously-reported ban.</summary>
bool CanRetract { get; }
/// <summary>Enqueues a retraction (e.g. a manual unban). No-op if unsupported.</summary>
void Retract(IPAddress address);
}

View file

@ -0,0 +1,155 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: ConnectionFilters.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using Server.Logging;
namespace Server.Network;
/// <summary>
/// Registry of the <see cref="IConnectionFilter"/> gates the accept path consults, and their lifecycle.
/// Filters are registered during the Configure sweep and the backing store is a plain array, so
/// <see cref="ShouldDeny"/> is an indexed loop over a field read — no enumerator, no closure, no
/// allocation. The whole accept path runs on the game loop, so no synchronization is needed.
/// </summary>
public static class ConnectionFilters
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConnectionFilters));
private static IConnectionFilter[] _filters = [];
public static IReadOnlyList<IConnectionFilter> Filters => _filters;
/// <summary>
/// Registers a gate (inversion of control, mirroring <c>BanChannel.Register</c>). Idempotent by
/// <see cref="IConnectionFilter.Name"/>. Filters are consulted in registration order, so register
/// the cheapest and most selective first — core registers the firewall before content is swept.
/// </summary>
public static void Register(IConnectionFilter filter)
{
if (filter == null)
{
return;
}
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
if (filters[i].Name == filter.Name)
{
return;
}
}
filter.Register();
var updated = new IConnectionFilter[_filters.Length + 1];
Array.Copy(_filters, updated, _filters.Length);
updated[^1] = filter;
_filters = updated;
logger.Information("Registered connection filter '{Name}'", filter.Name);
}
/// <summary>
/// True when any filter denies the connection. Short-circuits on the first denial;
/// <paramref name="deniedBy"/> names it for logging.
/// </summary>
public static bool ShouldDeny(IPAddress address, out string deniedBy)
{
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
// A faulty filter must not take down the accept loop for every connection.
try
{
if (filters[i].ShouldDeny(address))
{
deniedBy = filters[i].Name;
return true;
}
}
catch (Exception e)
{
Disable(filters[i], e);
}
}
deniedBy = null;
return false;
}
/// <summary>
/// Drops a filter that threw on the accept path: one that faults once faults for every subsequent
/// connection, costing an exception and a log line per accept. Failing open is deliberate — a broken
/// filter must not be able to deny every connection either.
/// </summary>
private static void Disable(IConnectionFilter filter, Exception e)
{
logger.Error(e, "Connection filter '{Name}' threw on the accept path; unregistering it", filter.Name);
var filters = _filters;
var updated = new List<IConnectionFilter>(filters.Length);
for (var i = 0; i < filters.Length; i++)
{
if (!ReferenceEquals(filters[i], filter))
{
updated.Add(filters[i]);
}
}
_filters = updated.ToArray();
}
public static void Start(CancellationToken token)
{
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
var filter = filters[i];
try
{
filter.Start(token);
}
catch (Exception e)
{
// A filter that cannot hydrate must not crash boot; it simply denies nothing.
logger.Error(e, "Connection filter '{Name}' failed to start; continuing without it", filter.Name);
}
}
}
public static void Stop()
{
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
var filter = filters[i];
try
{
filter.Stop();
}
catch (Exception e)
{
logger.Warning(e, "Connection filter '{Name}' threw while stopping", filter.Name);
}
}
}
internal static void ResetForTesting() => _filters = [];
}

View file

@ -1,168 +0,0 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: Firewall.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
using System.Runtime.CompilerServices;
using System.Threading;
namespace Server.Network;
public static class Firewall
{
[ThreadStatic]
private static InternalValidationEntry _validationEntry;
private static readonly ConcurrentDictionary<IPAddress, int> _isBlockedCache = [];
private static readonly ReaderWriterLockSlim _firewallLock = new(LockRecursionPolicy.NoRecursion);
private static int _firewallVersion;
private static readonly SortedSet<IFirewallEntry> _firewallSet = [];
public static int FirewallSetCount => _firewallSet.Count;
public static void ReadFirewallSet(Action<IReadOnlySet<IFirewallEntry>> callback)
{
_firewallLock.EnterReadLock();
try
{
callback(_firewallSet);
}
finally
{
_firewallLock.ExitReadLock();
}
}
internal static bool IsBlocked(IPAddress address)
{
if (_isBlockedCache.TryGetValue(address, out var blockVersion) && blockVersion == _firewallVersion)
{
return true;
}
if (_validationEntry == null)
{
_validationEntry = new InternalValidationEntry(address);
}
else
{
_validationEntry.Address = address;
}
if (CheckBlocked(_validationEntry))
{
_isBlockedCache[address] = _firewallVersion;
return true;
}
return false;
}
private static bool CheckBlocked(IFirewallEntry validationEntry)
{
if (_firewallSet.Count == 0)
{
return false;
}
_firewallLock.EnterReadLock();
try
{
var min = _firewallSet.Min;
if (validationEntry.CompareTo(min) < 0)
{
return false;
}
// Get all entries that are lower than our validation entry
var view = _firewallSet.GetViewBetween(min, validationEntry);
// Loop backward since there shouldn't be any entries where the Min address is higher than ours
foreach (var firewallEntry in view.Reverse())
{
if (firewallEntry.IsBlocked(validationEntry.MinIpAddress))
{
return true;
}
}
return view.Max?.IsBlocked(validationEntry.MinIpAddress) == true;
}
finally
{
_firewallLock.ExitReadLock();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool Add(IFirewallEntry firewallEntry)
{
_firewallLock.EnterWriteLock();
try
{
if (_firewallSet.Add(firewallEntry))
{
Interlocked.Increment(ref _firewallVersion); // Update version
return true;
}
return false;
}
finally
{
_firewallLock.ExitWriteLock();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool Remove(IFirewallEntry entry)
{
if (entry == null)
{
return false;
}
_firewallLock.EnterWriteLock();
try
{
if (_firewallSet.Remove(entry))
{
Interlocked.Increment(ref _firewallVersion); // Update version
return true;
}
return false;
}
finally
{
_firewallLock.ExitWriteLock();
}
}
private class InternalValidationEntry : BaseFirewallEntry
{
private UInt128 _address;
public IPAddress Address
{
set => _address = value.ToUInt128();
}
public override UInt128 MinIpAddress => _address;
public override UInt128 MaxIpAddress => _address;
public InternalValidationEntry(IPAddress ipAddress) => Address = ipAddress;
}
}

View file

@ -0,0 +1,146 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: ForeignProtocol.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
namespace Server.Network;
public enum ForeignProtocolKind
{
None,
Http,
Tls,
Ssh
}
public enum ForeignProtocolMatch
{
None,
/// <summary>A prefix matched, but more bytes are needed to be sure.</summary>
Incomplete,
Confirmed
}
/// <summary>
/// Identifies traffic that is positively some OTHER protocol (HTTP, TLS, SSH), rather than deciding whether
/// a connection is a good Ultima Online client.
/// </summary>
/// <remarks>
/// The direction matters. A legitimate client with encryption enabled when the shard expects none sends a
/// structurally correct connection whose payload is noise, because <c>LoginEncryption.ClientDecrypt</c> is a
/// byte-for-byte stream XOR: length survives, content does not. So "unreadable" cannot mean "hostile", while
/// "speaks HTTP" safely can. Nothing here assumes arrival framing; see
/// <c>dev-docs/ip-bans-and-allowlists.md</c>.
/// </remarks>
public static class ForeignProtocol
{
private const int RequiredBytes = 8;
private const int MaxTlsRecordLength = 16384;
public static ForeignProtocolMatch Identify(ReadOnlySpan<byte> buffer, out ForeignProtocolKind kind)
{
kind = ForeignProtocolKind.None;
// Too little to match a prefix; the caller's own short-read handling covers it.
if (buffer.Length < 4)
{
return ForeignProtocolMatch.None;
}
var candidate = MatchPrefix(buffer);
if (candidate == ForeignProtocolKind.None)
{
return ForeignProtocolMatch.None;
}
if (buffer.Length < RequiredBytes)
{
return ForeignProtocolMatch.Incomplete;
}
if (!Confirm(buffer, candidate))
{
return ForeignProtocolMatch.None;
}
kind = candidate;
return ForeignProtocolMatch.Confirmed;
}
private static ForeignProtocolKind MatchPrefix(ReadOnlySpan<byte> buffer)
{
// TLS handshake record: content type 0x16, major version 3, minor version 0..4 (SSL 3.0 - TLS 1.3).
if (buffer[0] == 0x16 && buffer[1] == 0x03 && buffer[2] <= 0x04)
{
return ForeignProtocolKind.Tls;
}
if (StartsWith(buffer, "SSH-"))
{
return ForeignProtocolKind.Ssh;
}
// HTTP request methods. Four bytes only selects a candidate; Confirm checks the request line.
if (StartsWith(buffer, "GET ") || StartsWith(buffer, "POST") || StartsWith(buffer, "HEAD") ||
StartsWith(buffer, "PUT ") || StartsWith(buffer, "OPTI") || StartsWith(buffer, "DELE") ||
StartsWith(buffer, "CONN") || StartsWith(buffer, "TRAC") || StartsWith(buffer, "PATC"))
{
return ForeignProtocolKind.Http;
}
return ForeignProtocolKind.None;
}
private static bool Confirm(ReadOnlySpan<byte> buffer, ForeignProtocolKind candidate)
{
if (candidate == ForeignProtocolKind.Tls)
{
// A seed can collide with the 0x16 0x03 0x0? prefix (that is just the address 22.3.x.x), so
// require a ClientHello inside a plausible record.
var recordLength = (buffer[3] << 8) | buffer[4];
return buffer[5] == 0x01 && recordLength is >= 4 and <= MaxTlsRecordLength;
}
// A UO client's fifth byte is a packet id (0x80, 0x91, 0xEF), none of them printable, so requiring
// the request line to continue in ASCII lets a seed that spells "GET " fall through.
for (var i = 4; i < buffer.Length && i < 16; i++)
{
if (!IsPrintableAscii(buffer[i]))
{
return false;
}
}
return true;
}
private static bool IsPrintableAscii(byte value) =>
value is >= 0x20 and <= 0x7E or (byte)'\r' or (byte)'\n' or (byte)'\t';
private static bool StartsWith(ReadOnlySpan<byte> buffer, string ascii)
{
for (var i = 0; i < ascii.Length; i++)
{
if (buffer[i] != (byte)ascii[i])
{
return false;
}
}
return true;
}
}

View file

@ -0,0 +1,60 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IConnectionFilter.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Net;
using System.Threading;
namespace Server.Network;
/// <summary>
/// A gate consulted for every inbound connection, before the socket is configured and before any
/// per-connection allocation. Implementations decide membership only — the accept path neither knows
/// nor cares where a filter's data comes from, so a filter may be a handful of admin-curated entries,
/// a millions-strong list hydrated from a file, or a query against something else entirely. Core owns
/// the question; content owns every answer (see <c>Firewall</c> and <c>BlocklistFilter</c> in UOContent).
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ShouldDeny"/> runs on the game loop once per accepted socket, which is the path that has
/// to survive a DDoS. Implementations MUST be allocation-free and O(log n) at worst, MUST NOT perform
/// I/O, and MUST NOT block. Anything expensive (parsing, reloading, reporting to an external service)
/// belongs off the loop or behind a bounded, non-blocking enqueue.
/// </para>
/// <para>
/// Side effects that a hit implies (contributing to <c>BanChannel</c>, promoting to an OS firewall,
/// suppressing duplicate reports) are the filter's own business, not the accept path's. This is why
/// <see cref="ShouldDeny"/> returns a bare bool: the accept path asks one question and does one thing.
/// </para>
/// </remarks>
public interface IConnectionFilter
{
/// <summary>Stable id for logging/config (e.g. <c>firewall</c>, <c>blocklist</c>).</summary>
string Name { get; }
/// <summary>Reads configuration. Called by <see cref="ConnectionFilters.Register"/>. No I/O beyond config.</summary>
void Register();
/// <summary>Starts any background hydration. The token is cancelled on shutdown.</summary>
void Start(CancellationToken token);
/// <summary>Flushes and tears down. Called during shutdown.</summary>
void Stop();
/// <summary>
/// True to deny the connection. Must be allocation-free and non-blocking; see the remarks on
/// <see cref="IConnectionFilter"/>.
/// </summary>
bool ShouldDeny(IPAddress address);
}

View file

@ -50,9 +50,6 @@ 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);
@ -83,15 +80,9 @@ public static class MovementThrottle
public static void Configure() public static void Configure()
{ {
_maxCredit = ServerConfiguration.GetOrUpdateSetting( _maxCredit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxCredit", _maxCredit);
"movementThrottle.maxCredit", _maxRttBonus = ServerConfiguration.GetOrUpdateSetting("movementThrottle.maxRttBonus", _maxRttBonus);
_maxCredit _hardQueueLimit = ServerConfiguration.GetOrUpdateSetting("movementThrottle.hardQueueLimit", _hardQueueLimit);
);
_hardQueueLimit = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.hardQueueLimit",
_hardQueueLimit
);
_movementHistorySize = ServerConfiguration.GetOrUpdateSetting( _movementHistorySize = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.movementHistorySize", "movementThrottle.movementHistorySize",
@ -103,6 +94,13 @@ 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
@ -112,11 +110,6 @@ public static class MovementThrottle
"movementThrottle.definiteRateThreshold", "movementThrottle.definiteRateThreshold",
_definiteRateThreshold _definiteRateThreshold
); );
_debugLogging = ServerConfiguration.GetOrUpdateSetting(
"movementThrottle.debugLogging",
_debugLogging
);
} }
/// <summary> /// <summary>
@ -191,15 +184,16 @@ 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 (_debugLogging && ns._movementLogging) if (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.RawName, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit mobile, delta, earlyAmount, prevCredit, ns._movementCredit, dynamicCredit
); );
} }
@ -208,11 +202,11 @@ public static class MovementThrottle
return; return;
} }
if (_debugLogging && ns._movementLogging) if (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.RawName, delta, earlyAmount, ns._movementCredit, dynamicCredit mobile, delta, earlyAmount, ns._movementCredit, dynamicCredit
); );
} }
@ -227,11 +221,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 (_debugLogging && ns._movementLogging && ns._movementCredit != prevCredit) if (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.RawName, delta, prevCredit, ns._movementCredit, dynamicCredit mobile, delta, prevCredit, ns._movementCredit, dynamicCredit
); );
} }
} }
@ -247,12 +241,9 @@ public static class MovementThrottle
{ {
if (!mobile.Move(dir)) if (!mobile.Move(dir))
{ {
if (_debugLogging && ns._movementLogging) if (ns._movementLogging)
{ {
logger.Debug( logger.Debug("[Execute] {Name}: Move FAILED dir={Dir} seq={Seq} -> reject+reset", mobile, dir, seq);
"[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.)
@ -260,11 +251,11 @@ public static class MovementThrottle
return; return;
} }
if (_debugLogging && ns._movementLogging) if (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.RawName, dir, seq, ns._nextMovementTime - Core.TickCount mobile, dir, seq, ns._nextMovementTime - Core.TickCount
); );
} }
@ -304,11 +295,11 @@ public static class MovementThrottle
ns._hasQueuedMovements = true; ns._hasQueuedMovements = true;
_netStatesWithQueuedMovements.Add(ns); _netStatesWithQueuedMovements.Add(ns);
if (_debugLogging && ns._movementLogging) if (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?.RawName, dir, seq, ns._movementQueue.Count ns.Mobile, dir, seq, ns._movementQueue.Count
); );
} }
} }
@ -320,7 +311,6 @@ public static class MovementThrottle
{ {
ns.SendMovementRej(seq, mobile); ns.SendMovementRej(seq, mobile);
ns.ResetMovementState(); ns.ResetMovementState();
_netStatesWithQueuedMovements.Remove(ns);
} }
/// <summary> /// <summary>
@ -333,20 +323,18 @@ public static class MovementThrottle
return; return;
} }
// Process each NetState with queued movements foreach (var ns in _netStatesWithQueuedMovements)
// Use a snapshot to avoid modification during iteration
var toProcess = new List<NetState>(_netStatesWithQueuedMovements);
for (var i = 0; i < toProcess.Count; i++)
{ {
var ns = toProcess[i]; if (ns.Running)
if (!ns.Running) {
ProcessMovementQueue(ns);
if (ns._hasQueuedMovements)
{ {
_netStatesWithQueuedMovements.Remove(ns);
continue; continue;
} }
}
ProcessMovementQueue(ns); _netStatesWithQueuedMovements.Remove(ns);
} }
} }
@ -356,6 +344,7 @@ 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);
@ -374,7 +363,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) if (now - ns._nextMovementTime < 0)
{ {
// Not yet - leave remaining items in queue for next Slice // Not yet - leave remaining items in queue for next Slice
break; break;
@ -394,11 +383,11 @@ public static class MovementThrottle
// Execute the move // Execute the move
if (!mobile.Move(movement.Direction)) if (!mobile.Move(movement.Direction))
{ {
if (_debugLogging && ns._movementLogging) if (ns._movementLogging)
{ {
logger.Debug( logger.Debug(
"[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})", "[Queue] {Name}: dequeued FAILED dir={Dir} (remaining={Remaining})",
mobile.RawName, movement.Direction, remaining mobile, movement.Direction, remaining
); );
} }
@ -407,12 +396,12 @@ public static class MovementThrottle
return; return;
} }
if (_debugLogging && ns._movementLogging) if (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.RawName, movement.Direction, remaining, waited >= 0 ? waited : 0 mobile, movement.Direction, remaining, waited >= 0 ? waited : 0
); );
} }
@ -430,10 +419,6 @@ 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>
@ -469,7 +454,6 @@ 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)
@ -484,7 +468,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?.RawName ?? "Unknown", mobile,
ns.Account?.Username ?? "Unknown", ns.Account?.Username ?? "Unknown",
_hardQueueLimit, _hardQueueLimit,
ns.Address ns.Address
@ -516,7 +500,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._lastMovementRecordTime > 0 var interval = ns._hasMovementRecord
? (int)(now - ns._lastMovementRecordTime) ? (int)(now - ns._lastMovementRecordTime)
: -1; // -1 indicates first movement (no previous time) : -1; // -1 indicates first movement (no previous time)
@ -525,6 +509,7 @@ 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
@ -544,19 +529,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 (_debugLogging && mobile?.RawName != null) if (ns._movementLogging)
{ {
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.RawName, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action mobile, interval, _maxChainGap, avgRtt, ns.HasStableConnection, action
); );
} }
} }
else if (_debugLogging && mobile?.RawName != null) else if (ns._movementLogging)
{ {
logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile.RawName); logger.Debug("[Movement] {Name}: SKIP recording (first in chain)", mobile);
} }
return; return;
@ -572,12 +557,9 @@ 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 (_debugLogging && mobile?.RawName != null) if (ns._movementLogging)
{ {
logger.Debug( logger.Debug("[Movement] {Name}: SKIP direction-only change (preserves interval measurement)", mobile);
"[Movement] {Name}: SKIP direction-only change (preserves interval measurement)",
mobile.RawName
);
} }
return; return;
} }
@ -613,15 +595,16 @@ public static class MovementThrottle
} }
ns._lastMovementRecordTime = now; ns._lastMovementRecordTime = now;
ns._hasMovementRecord = true;
// Debug logging // Debug logging
if (_debugLogging && mobile?.RawName != null) if (ns._movementLogging)
{ {
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.RawName, interval, cost, record.QueueDepth, mobile, interval, cost, record.QueueDepth,
flags, historyCount, _movementHistorySize, ns.AverageRtt flags, historyCount, _movementHistorySize, ns.AverageRtt
); );
} }
@ -814,7 +797,7 @@ public static class MovementThrottle
var averageRtt = ns.AverageRtt; var averageRtt = ns.AverageRtt;
// Detailed rate breakdown for debugging // Detailed rate breakdown for debugging
if (_debugLogging) if (ns._movementLogging)
{ {
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);
@ -977,19 +960,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 (_debugLogging && ns.Mobile?.RawName != null) if (ns._movementLogging)
{ {
var (burstSize, _) = DetectRecentBurst(ns); var (burstSize, _) = DetectRecentBurst(ns);
var probeStatus = ns._rttProbeTime > 0 ? "pending" : "idle"; var probeStatus = ns._rttProbePending ? "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.RawName, rate, sampleCount, verdict, confidence, queueDepth, burstSize, ns._consecutiveHighRateSeconds ns.Mobile, 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
); );
} }
@ -1025,11 +1008,11 @@ public static class MovementThrottle
if (shouldNotify) if (shouldNotify)
{ {
if (_debugLogging) if (ns._movementLogging)
{ {
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?.RawName, rate, verdict, confidence urgency, ns.Mobile, rate, verdict, confidence
); );
} }
NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency); NotifyStaff(ns, rate, sampleCount, confidence, verdict, urgency);
@ -1054,11 +1037,12 @@ public static class MovementThrottle
var now = Core.TickCount; var now = Core.TickCount;
// Rate-limit notifications per player // Rate-limit notifications per player
if (now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown) if (ns._speedHackNotified && now - ns._lastSpeedHackNotification < _speedHackNotificationCooldown)
{ {
return; return;
} }
ns._speedHackNotified = true;
ns._lastSpeedHackNotification = now; ns._lastSpeedHackNotification = now;
var mobile = ns.Mobile; var mobile = ns.Mobile;
@ -1070,7 +1054,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?.RawName ?? "Unknown", mobile,
ns.Account?.Username ?? "Unknown", ns.Account?.Username ?? "Unknown",
rate, rate,
sampleCount, sampleCount,
@ -1138,7 +1122,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,7 +15,6 @@
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;
@ -70,23 +69,24 @@ 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 int _sustainedQueueDepth; // Tracks sustained high queue depth internal long _lastQueueDepthCheck = Core.TickCount; // Throttle depth check frequency
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 internal long _lastMovementRecordTime; // For calculating intervals (valid only when _hasMovementRecord)
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 internal long _lastSpeedHackNotification; // Rate-limit notifications (valid only when _speedHackNotified)
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; // Start of current 1-second window internal long _movementWindowStart = Core.TickCount; // 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,10 +100,9 @@ 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
_lastMovementRecordTime = 0; _hasMovementRecord = false;
_movementHistoryIndex = 0; _movementHistoryIndex = 0;
_movementHistoryFull = false; _movementHistoryFull = false;
@ -113,7 +112,7 @@ public partial class NetState
_rttProbeInterval = RttProbeIntervalNormal; _rttProbeInterval = RttProbeIntervalNormal;
// Reset packet rate window // Reset packet rate window
_movementWindowStart = 0; _movementWindowStart = Core.TickCount;
_movementsInWindow = 0; _movementsInWindow = 0;
} }
@ -165,17 +164,19 @@ 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 long _rttProbeTime; // When we sent the probe (0 = not waiting) internal bool _rttProbePending; // True while waiting for a probe response
internal long _lastRtt; // Most recent RTT measurement internal long _rttProbeTime; // When we sent the probe (valid only when _rttProbePending)
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; // When to send next probe internal long _nextRttProbe = Core.TickCount; // When to send next probe
internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval internal int _rttProbeInterval = RttProbeIntervalNormal; // Current probe interval
// High-resolution timestamp for RTT measurement (Stopwatch ticks, not game loop ticks) /// <summary>
private long _rttProbeTimestampHiRes; /// Gets the most recent RTT measurement, or 0 if none has been recorded.
/// </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.
@ -206,23 +207,22 @@ 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 (_rttProbeTime > 0) if (_rttProbePending)
{ {
// 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)
{ {
_rttProbeTime = 0; _rttProbePending = false;
_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 (_nextRttProbe == 0 || now >= _nextRttProbe) if (now - _nextRttProbe >= 0)
{ {
_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,10 +242,9 @@ 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 (_rttProbeTime <= 0) if (!_rttProbePending)
{ {
// Not expecting a response (client-initiated version send) - ignore silently // Not expecting a response (client-initiated version send) - ignore silently
return; return;
@ -253,19 +252,15 @@ 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 (HiRes: {RttHiRes:F2}ms)", "[RTT-Response] {Account}: {Rtt}ms",
Account?.Username ?? _toString, rtt, rttHiResUs / 1000.0 Account?.Username ?? _toString, rtt
); );
} }
_rttProbeTime = 0; _rttProbePending = false;
_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)
@ -285,7 +280,6 @@ 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

@ -19,6 +19,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.NetworkInformation; using System.Net.NetworkInformation;
using System.Network; using System.Network;
using System.Numerics;
namespace Server.Network; namespace Server.Network;
@ -29,7 +30,8 @@ public partial class NetState
{ {
// Buffer sizes // Buffer sizes
private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers private const int RecvBufferSize = 1024 * 64; // 64KB recv buffers
private const int SendBufferSize = 1024 * 256; // 256KB send buffers private const int DefaultSendBufferSize = 1024 * 256; // 256KB send buffers
private const int MinSendBufferSize = 1024 * 64; // Platform allocation granularity
private const int MaxConnections = 4096; // Max concurrent connections private const int MaxConnections = 4096; // Max concurrent connections
private static readonly Queue<NetState> _disposed = []; private static readonly Queue<NetState> _disposed = [];
@ -41,7 +43,9 @@ public partial class NetState
// NetState storage indexed by RingSocket.Id // NetState storage indexed by RingSocket.Id
private static readonly NetState[] _netStates = new NetState[MaxConnections]; private static readonly NetState[] _netStates = new NetState[MaxConnections];
// Events buffer for ProcessCompletions // Events buffer for ProcessCompletions. Bounded by one event per peeked completion
// (maxSockets), doubled for headroom. Undersizing drops DataReceived events whose bytes were
// already committed, leaving them unparsed until the next recv completes.
private static readonly RingSocketEvent[] _events = new RingSocketEvent[MaxConnections * 2]; private static readonly RingSocketEvent[] _events = new RingSocketEvent[MaxConnections * 2];
// Listener management // Listener management
@ -67,6 +71,24 @@ 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>
@ -88,20 +110,61 @@ public partial class NetState
// Initialize IP rate limiter // Initialize IP rate limiter
_ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token); _ipRateLimiter = new IPRateLimiter(10, 10000, 1000, 2.0, 3_600_000, Core.ClosingTokenSource.Token);
// Sends in flight per connection; honoured by RIO only (see IIORingGroup). Costs a
// request-queue and completion-queue slot per send, not another buffer. Worst-case added
// latency is roughly completion RTT / this value.
var maxOutstandingSends = ServerConfiguration.GetOrUpdateSetting("network.maxOutstandingSends", 32);
// Initialize IORingGroup // Initialize IORingGroup
var ring = IORingGroup.Create(queueSize: MaxConnections * 2, maxConnections: MaxConnections); var ring = IORingGroup.Create(
queueSize: MaxConnections * 2,
maxConnections: MaxConnections,
maxOutstandingSends: maxOutstandingSends
);
// Per-connection send buffer: the lever for "send buffer exhausted" disconnects, and the
// per-connection memory ceiling.
var sendBufferSize = GetSendBufferSize();
// Create socket manager which handles buffer pools and socket lifecycle // Create socket manager which handles buffer pools and socket lifecycle
_socketManager = new RingSocketManager( _socketManager = new RingSocketManager(
ring, ring,
maxSockets: MaxConnections, maxSockets: MaxConnections,
recvBufferSize: RecvBufferSize, recvBufferSize: RecvBufferSize,
sendBufferSize: SendBufferSize, sendBufferSize: sendBufferSize,
initialBufferSlabs: 8, initialBufferSlabs: 8,
maxBufferSlabs: 32 maxBufferSlabs: 32
); );
} }
/// <summary>
/// Reads the configured send buffer size, coerced to a power of two of at least the platform
/// allocation granularity. IORingBuffer requires this and would otherwise throw at socket
/// creation rather than at startup.
/// </summary>
private static int GetSendBufferSize()
{
var configured = ServerConfiguration.GetOrUpdateSetting("network.sendBufferSize", DefaultSendBufferSize);
var size = Math.Max(MinSendBufferSize, configured);
if (!BitOperations.IsPow2(size))
{
size = (int)BitOperations.RoundUpToPowerOf2((uint)size);
}
if (size != configured)
{
logger.Warning(
"network.sendBufferSize {Configured} is not a power of two of at least {Minimum}; using {Adjusted}",
configured,
MinSendBufferSize,
size
);
}
return size;
}
/// <summary> /// <summary>
/// Starts the network server on configured listening addresses. /// Starts the network server on configured listening addresses.
/// </summary> /// </summary>
@ -224,10 +287,19 @@ public partial class NetState
if (_ipRateLimiter != null && !_ipRateLimiter.Verify(remoteIP, out var totalAttempts)) if (_ipRateLimiter != null && !_ipRateLimiter.Verify(remoteIP, out var totalAttempts))
{ {
logger.Debug("{Address} Past IP limit threshold ({TotalAttempts})", remoteIP, totalAttempts); logger.Debug("{Address} Past IP limit threshold ({TotalAttempts})", remoteIP, totalAttempts);
}
else if (Firewall.IsBlocked(remoteIP)) if (Bans.BanConfiguration.Settings.ReportRateLimitTrips)
{ {
logger.Debug("{Address} Firewalled", remoteIP); // Enqueue-only contribution; NOT added to the local firewall set (the limiter already
// gates it here and the OS bouncer drops it at the kernel).
Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.AutoBanDuration, Bans.BanReasons.RateLimit);
}
}
else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy))
{
// Whatever a hit implies (persisting, promoting to an OS bouncer, contributing to the
// ban channel) is the filter's own business; the accept path just drops the socket.
logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy);
} }
else else
{ {
@ -308,6 +380,18 @@ public partial class NetState
// Socket must have finished the entire authentication process or be forcibly disconnected // Socket must have finished the entire authentication process or be forcibly disconnected
if (!ns.SentFirstPacket || !ns.Seeded) if (!ns.SentFirstPacket || !ns.Seeded)
{ {
// Only the totally silent ones are evidence. A connection that sent SOME data and ran out of
// time is far more likely a slow link, and banning those makes the player retry, trip the
// rate limiter, and compound it into an hours-long ban.
if (!ns._receivedData && Bans.BanConfiguration.Settings.ReportBadConnects)
{
Bans.BanChannel.Report(
ns.Address,
Bans.BanConfiguration.Settings.BadConnectDuration,
Bans.BanReasons.SilentConnect
);
}
ns.Disconnect(null); ns.Disconnect(null);
// Force immediate cleanup - these are unauthenticated connections // Force immediate cleanup - these are unauthenticated connections
@ -484,6 +568,11 @@ public partial class NetState
return; return;
} }
if (bytesReceived > 0)
{
ns._receivedData = true;
}
// Data is already committed to buffer by RingSocketManager // Data is already committed to buffer by RingSocketManager
// Decode if encryption is enabled // Decode if encryption is enabled
ns.DecryptRecvBuffer(bytesReceived); ns.DecryptRecvBuffer(bytesReceived);

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 IReadOnlySet<NetState> Instances => _instances; public static HashSet<NetState> Instances => _instances;
private readonly string _toString; private readonly string _toString;
private ClientVersion _version; private ClientVersion _version;
@ -60,6 +60,10 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
internal ProtocolState _protocolState = ProtocolState.AwaitingSeed; internal ProtocolState _protocolState = ProtocolState.AwaitingSeed;
private bool _packetLogging; private bool _packetLogging;
// Whether ANY inbound bytes have arrived: what separates a slow client from a socket held open on
// purpose. See BanSettings.ReportBadConnects.
internal bool _receivedData;
// Managed socket with buffers (handles lifecycle automatically) // Managed socket with buffers (handles lifecycle automatically)
internal RingSocket _socket; internal RingSocket _socket;
@ -105,9 +109,6 @@ 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)";
@ -162,7 +163,7 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
public bool BlockAllPackets { get; set; } public bool BlockAllPackets { get; set; }
public List<SecureTrade> Trades { get; } public List<SecureTrade> Trades { get; private set; }
public bool Seeded { get; set; } public bool Seeded { get; set; }
@ -256,8 +257,18 @@ 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;
@ -276,8 +287,18 @@ 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();
@ -287,11 +308,21 @@ 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];
@ -307,6 +338,11 @@ 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];
@ -332,7 +368,11 @@ 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;
@ -444,17 +484,36 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
} }
var length = span.Length; var length = span.Length;
if (length <= 0 || !GetSendBuffer(out var buffer)) if (length <= 0)
{ {
return; return;
} }
// Never drop silently: the client would stay connected while missing game state.
if (!GetSendBuffer(out var buffer))
{
SendBufferExhausted(length, 0);
return;
}
try try
{ {
// Apply encoding first (e.g., compression from UOContent) // Apply encoding first (e.g., compression from UOContent)
if (CompressionEnabled) if (CompressionEnabled)
{ {
length = NetworkCompression.Compress(span, buffer); length = NetworkCompression.Compress(span, buffer);
// 0 means nothing was written, whether it did not fit or the input was too large.
if (length <= 0)
{
SendBufferExhausted(span.Length, buffer.Length);
return;
}
}
else if (span.Length > buffer.Length)
{
SendBufferExhausted(span.Length, buffer.Length);
return;
} }
else else
{ {
@ -484,6 +543,31 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
} }
} }
/// <summary>
/// Handles a packet that cannot be placed in the send buffer.
/// </summary>
/// <remarks>
/// High unacked means a slow client holding the buffer; needed approaching capacity means the
/// buffer is too small for this shard and network.sendBufferSize should be raised.
/// </remarks>
private void SendBufferExhausted(int needed, int writable)
{
var sendBuffer = _socket?.SendBuffer;
var unacked = sendBuffer?.InFlightBytes ?? 0;
var capacity = sendBuffer?.PhysicalSize ?? 0;
logger.Warning(
"{NetState}: send buffer exhausted - needed {Needed} bytes, {Writable} writable, {Unacked} awaiting acknowledgement, {Capacity} capacity. Raise network.sendBufferSize (power of two) if this recurs on healthy connections.",
this,
needed,
writable,
unacked,
capacity
);
Disconnect($"Send buffer exhausted (needed {needed}, writable {writable}, unacked {unacked}, capacity {capacity})");
}
private void StartPacketLog() private void StartPacketLog()
{ {
try try
@ -577,6 +661,37 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
{ {
case ProtocolState.AwaitingSeed: case ProtocolState.AwaitingSeed:
{ {
// Traffic that is positively another protocol. Unlike "does not look like a
// good client", this cannot misfire on a misconfigured one.
var foreign = ForeignProtocol.Identify(buffer, out var foreignKind);
if (foreign == ForeignProtocolMatch.Incomplete)
{
_parserState = ParserState.AwaitingPartialPacket;
break;
}
if (foreign == ForeignProtocolMatch.Confirmed)
{
logger.Debug(
"{Address} spoke {Protocol} on the game port; disconnecting",
Address,
foreignKind
);
if (Bans.BanConfiguration.Settings.ReportBadConnects)
{
Bans.BanChannel.Report(
Address,
Bans.BanConfiguration.Settings.BadConnectDuration,
Bans.BanReasons.ForeignProtocol
);
}
Disconnect(string.Empty);
return;
}
if (packetId == 0xEF) if (packetId == 0xEF)
{ {
_parserState = ParserState.ProcessingPacket; _parserState = ParserState.ProcessingPacket;
@ -592,6 +707,18 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
if (newSeed == 0) if (newSeed == 0)
{ {
// No real client sends a zero seed, so this is deliberate garbage
// rather than a damaged connection — unlike the short-read branch
// below, which a fragmented first segment can reach honestly.
if (Bans.BanConfiguration.Settings.ReportBadConnects)
{
Bans.BanChannel.Report(
Address,
Bans.BanConfiguration.Settings.BadConnectDuration,
Bans.BanReasons.InvalidSeed
);
}
Disconnect(string.Empty); Disconnect(string.Empty);
return; return;
} }
@ -603,8 +730,11 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
_parserState = ParserState.AwaitingNextPacket; _parserState = ParserState.AwaitingNextPacket;
_protocolState = ProtocolState.GameServer_AwaitingGameServerLogin; _protocolState = ProtocolState.GameServer_AwaitingGameServerLogin;
} }
else // Don't allow partial packets on initial connection, just disconnect them. else
{ {
// Disconnect rather than wait. Waiting would hold a connection slot for
// the full ConnectingSocketIdleLimit per one- or two-byte client, which
// is what a flood sends. Only pre-0xEF clients reach here.
Disconnect(string.Empty); Disconnect(string.Empty);
} }
break; break;
@ -1082,8 +1212,16 @@ public partial class NetState : IComparable<NetState>, IValueLinkListNode<NetSta
var a = Account; var a = Account;
Menus.Clear(); Menus?.Clear();
HuePickers.Clear(); Menus = null;
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

@ -73,7 +73,9 @@ public static class NetworkCompression
public static int Compress(ReadOnlySpan<byte> input, Span<byte> output) public static int Compress(ReadOnlySpan<byte> input, Span<byte> output)
{ {
if (input.Length > DefiniteOverflow) // output.Length < 4 underflows safeOutputLength below (nuint), defeating the hot loop's
// bounds check. Reachable whenever the send buffer is nearly full.
if (input.Length > DefiniteOverflow || output.Length < 4)
{ {
return 0; return 0;
} }

View file

@ -55,6 +55,12 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
private int _pos; private int _pos;
private char[]? _arrayToReturnToPool; private char[]? _arrayToReturnToPool;
/// <summary>
/// True while GetProperties is populating this list. Set by the owning entity so a nested
/// InvalidateProperties can be refused instead of Reset()ing a build already in flight.
/// </summary>
internal bool IsBuilding { get; set; }
public ObjectPropertyList(IEntity? e) public ObjectPropertyList(IEntity? e)
{ {
Entity = e; Entity = e;
@ -319,8 +325,23 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
private static int GetDefaultLength(int literalLength, int formattedCount) => private static int GetDefaultLength(int literalLength, int formattedCount) =>
Math.Max(256, literalLength + formattedCount * 11); Math.Max(256, literalLength + formattedCount * 11);
// Reset()/Dispose() return the scratch buffer to the pool. If either lands while a `$"..."`
// handler is still appending, re-rent rather than spanning a null array and throwing out of
// GetProperties. Mobile/Item hold the primary guard; this covers any other caller.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void EnsureInterpolationBuffer()
{
if (_arrayToReturnToPool == null)
{
_arrayToReturnToPool = STArrayPool<char>.Shared.Rent(256);
_pos = 0;
}
}
public void AppendLiteral(string value) public void AppendLiteral(string value)
{ {
EnsureInterpolationBuffer();
if (value.Length == 1) if (value.Length == 1)
{ {
var chars = _arrayToReturnToPool.AsSpan(); var chars = _arrayToReturnToPool.AsSpan();
@ -354,6 +375,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
public void AppendFormatted<T>(T value) public void AppendFormatted<T>(T value)
{ {
EnsureInterpolationBuffer();
string? s; string? s;
if (value is IFormattable) if (value is IFormattable)
{ {
@ -384,9 +407,11 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
public void AppendFormatted<T>(T value, string? format) public void AppendFormatted<T>(T value, string? format)
{ {
// We support localization '#' cliloc formatter for custom property lists EnsureInterpolationBuffer();
// This allows someone to build an IPropertyList that creates HTML using the same syntax as LocalizationInterpolationHandler
if (format == "#") // '#' marks an integer argument as a cliloc ("#<value>"). Integers only -- a float/double/decimal
// '#' is the standard numeric format, not a cliloc marker.
if (format == "#" && value is int or uint or long or ulong or short or ushort or byte or sbyte)
{ {
AppendLiteral("#"); AppendLiteral("#");
format = null; format = null;
@ -442,6 +467,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
public void AppendFormatted(ReadOnlySpan<char> value) public void AppendFormatted(ReadOnlySpan<char> value)
{ {
EnsureInterpolationBuffer();
if (value.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..))) if (value.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)))
{ {
_pos += value.Length; _pos += value.Length;
@ -454,6 +481,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null) public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null)
{ {
EnsureInterpolationBuffer();
var leftAlign = false; var leftAlign = false;
if (alignment < 0) if (alignment < 0)
{ {
@ -488,6 +517,8 @@ public sealed class ObjectPropertyList : IPropertyList, IDisposable
public void AppendFormatted(string? value) public void AppendFormatted(string? value)
{ {
EnsureInterpolationBuffer();
if (value?.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)) == true) if (value?.TryCopyTo(_arrayToReturnToPool.AsSpan(_pos..)) == true)
{ {
_pos += value.Length; _pos += value.Length;

View file

@ -74,6 +74,12 @@ 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,6 +37,8 @@ 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,6 +384,7 @@ 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)
@ -407,6 +408,21 @@ 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,9 +114,10 @@ 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 header carries the type table // v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor
// (name lengths vary — 64 bytes per entry is a staging hint, not a contract). // and the type table (name lengths vary — 64 bytes per entry is a staging hint, not
var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count; // a contract).
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;
@ -142,7 +143,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
binPosition += _selfLength; binPosition += _selfLength;
} }
idx.Write(4); // Version idx.Write(5); // Version
// One anchor for the whole save: the world is frozen from the moment it is stamped.
idx.Write(World.SaveStartTime.Ticks);
// The type table is fully known at freeze (AddEntity diverts to the pending // 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
@ -494,6 +498,18 @@ 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);
@ -660,6 +676,9 @@ 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);
@ -667,7 +686,10 @@ 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,7 +98,13 @@ 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,6 +43,12 @@ 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
@ -52,6 +58,37 @@ 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,7 +40,11 @@ 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,6 +43,8 @@ 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,14 +34,13 @@
</Target> </Target>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Logger\Logger.csproj" /> <ProjectReference Include="..\Logger\Logger.csproj" />
<PackageReference Include="IORingGroup" Version="1.0.7" /> <PackageReference Include="IORingGroup" Version="1.0.10" />
<PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
<PackageReference Include="LibDeflate.Bindings" Version="1.0.3" /> <PackageReference Include="LibDeflate.Bindings" Version="1.0.4" />
<PackageReference Include="System.IO.Hashing" Version="10.0.10" /> <PackageReference Include="System.IO.Hashing" Version="10.0.11" />
<PackageReference Include="ModernUO.Serialization.Annotations" Version="2.14.2" /> <PackageReference Include="ModernUO.Serialization.Annotations" Version="4.0.0" />
<PackageReference Include="ModernUO.Serialization.Generator" Version="2.14.3" /> <PackageReference Include="ModernUO.Serialization.Generator" Version="4.0.0" PrivateAssets="all" />
<PackageReference Update="Serilog" Version="4.4.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="Migrations/*.v*.json" /> <AdditionalFiles Include="Migrations/*.v*.json" />

View file

@ -51,8 +51,15 @@ 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

@ -0,0 +1,240 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IPAddressUtility.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Numerics;
namespace Server;
/// <summary>
/// Low-level IPAddress conversion and parsing helpers shared by the firewall, ban channel, and
/// blocklist. All members are allocation-free (stack buffers only) so they are safe on hot accept
/// paths and inside tight parse loops.
/// </summary>
public static class IPAddressUtility
{
// Converts an IPAddress to a UInt128 in IPv6 format.
// The IsIPv4MappedToIPv6 clause below looks redundant (the BCL only ever sets it on InterNetworkV6),
// but it guards the v4 -> UInt128 -> IPAddress round-trip, which can return a mapped v6 address for
// what is really a v4 one.
//TODO Rework as an explicit "to canonical v6 bits" step that needs no family check
// (see dev-docs/networking-packets.md, "IP Address Normalization")
public static UInt128 ToUInt128(this IPAddress ip)
{
if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6)
{
Span<byte> integer = stackalloc byte[4];
return !ip.TryWriteBytes(integer, out _)
? (UInt128)0
: new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer));
}
Span<byte> bytes = stackalloc byte[16];
if (!ip.TryWriteBytes(bytes, out _))
{
return 0;
}
var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]);
var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8));
return new UInt128(high, low);
}
// Converts a UInt128 in IPv6 format to an IPAddress
public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false)
{
// IPv4 mapped IPv6 address
if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL)
{
var newAddress = IPAddress.HostToNetworkOrder((int)value);
return new IPAddress(unchecked((uint)newAddress));
}
Span<byte> bytes = stackalloc byte[16]; // 128 bits for IPv6 address
((IBinaryInteger<UInt128>)value).WriteBigEndian(bytes);
return new IPAddress(bytes);
}
/// <summary>
/// Parses <c>a.b.c.d/n</c>, <c>::/n</c>, or a bare address (treated as a single-host range) into an
/// inclusive <see cref="UInt128"/> range in normalized IPv6 form. A bare IPv4 prefix is widened by 96
/// bits so v4 and v6 ranges are directly comparable. Returns false on anything malformed.
/// </summary>
public static bool TryParseCidrRange(ReadOnlySpan<char> cidr, out UInt128 min, out UInt128 max)
{
min = default;
max = default;
var slash = cidr.IndexOf('/');
if (!IPAddress.TryParse(slash >= 0 ? cidr[..slash] : cidr, out var ip))
{
return false;
}
var isV6 = ip.AddressFamily == AddressFamily.InterNetworkV6;
var maxPrefixLength = isV6 ? 128 : 32;
int prefixLength;
if (slash < 0)
{
prefixLength = maxPrefixLength;
}
else if (!int.TryParse(cidr[(slash + 1)..], out prefixLength) ||
prefixLength < 0 || prefixLength > maxPrefixLength)
{
return false;
}
if (!isV6)
{
prefixLength += 96; // 32 -> 128
}
Span<byte> bytes = stackalloc byte[16];
ip.WriteMappedIPv6To(bytes);
min = Utility.CreateCidrAddress(bytes, prefixLength, false);
max = Utility.CreateCidrAddress(bytes, prefixLength, true);
return true;
}
/// <summary>Extracts the big-endian uint of an <see cref="AddressFamily.InterNetwork"/> address.</summary>
public static bool TryV4(IPAddress ip, out uint v)
{
Span<byte> b = stackalloc byte[4];
if (ip.TryWriteBytes(b, out var n) && n == 4)
{
v = ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3];
return true;
}
v = 0;
return false;
}
/// <summary>
/// Extracts the embedded v4 uint from a v4-mapped-v6 address directly from the mapped bytes,
/// avoiding the allocation of <see cref="IPAddress.MapToIPv4"/>.
/// </summary>
public static bool TryMappedV4(IPAddress ip, out uint v)
{
Span<byte> b = stackalloc byte[16];
if (ip.TryWriteBytes(b, out var n) && n == 16)
{
v = ((uint)b[12] << 24) | ((uint)b[13] << 16) | ((uint)b[14] << 8) | b[15];
return true;
}
v = 0;
return false;
}
/// <summary>Parses a dotted-quad IPv4 literal into a big-endian uint. Allocation-free, strict.</summary>
public static bool TryParseV4(ReadOnlySpan<char> s, out uint v)
{
v = 0;
uint acc = 0;
int octet = 0, digits = 0, dots = 0;
for (var i = 0; i < s.Length; i++)
{
var c = s[i];
if (c == '.')
{
if (digits == 0 || octet > 255)
{
return false;
}
acc = (acc << 8) | (uint)octet;
dots++;
octet = 0;
digits = 0;
}
else if (c is >= '0' and <= '9')
{
octet = octet * 10 + (c - '0');
if (++digits > 3)
{
return false;
}
}
else
{
return false;
}
}
if (dots != 3 || digits == 0 || octet > 255)
{
return false;
}
v = (acc << 8) | (uint)octet;
return true;
}
/// <summary>
/// UTF-8/ASCII byte overload of <see cref="TryParseV4(ReadOnlySpan{char}, out uint)"/>, mirroring its
/// validation exactly so the blocklist can parse dotted-quads straight from file bytes with no
/// per-line string allocation.
/// </summary>
public static bool TryParseV4(ReadOnlySpan<byte> s, out uint v)
{
v = 0;
uint acc = 0;
int octet = 0, digits = 0, dots = 0;
for (var i = 0; i < s.Length; i++)
{
var c = s[i];
if (c == (byte)'.')
{
if (digits == 0 || octet > 255)
{
return false;
}
acc = (acc << 8) | (uint)octet;
dots++;
octet = 0;
digits = 0;
}
else if (c is >= (byte)'0' and <= (byte)'9')
{
octet = octet * 10 + (c - '0');
if (++digits > 3)
{
return false;
}
}
else
{
return false;
}
}
if (dots != 3 || digits == 0 || octet > 255)
{
return false;
}
v = (acc << 8) | (uint)octet;
return true;
}
}

View file

@ -1,6 +1,7 @@
using System;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using Server.Network; using Server.Collections;
namespace Server; namespace Server;
@ -14,36 +15,42 @@ public static class NetworkUtilities
_ => false _ => false
}; };
private static readonly IFirewallEntry[] _privateNetworkV4 = // These are constant reserved ranges, not firewall entries -- they only ever answer "is this address
[ // in one of these blocks?", which is exactly what SortedRangeIndex is for. Building them through the
new CidrFirewallEntry("127.0.0.1/8"), // firewall entry types was a convenience that made core depend on the firewall for something that has
new CidrFirewallEntry("192.168.0.0/16"), // nothing to do with banning.
new CidrFirewallEntry("10.0.0.0/8"), private static readonly SortedRangeIndex<UInt128> _privateNetworkV4 = BuildIndex(
new CidrFirewallEntry("172.16.0.0/12"), "127.0.0.1/8",
new CidrFirewallEntry("169.254.0.0/16"), "192.168.0.0/16",
new CidrFirewallEntry("100.64.0.0/10") "10.0.0.0/8",
]; "172.16.0.0/12",
"169.254.0.0/16",
"100.64.0.0/10"
);
private static readonly IFirewallEntry[] _privateNetworkV6 = private static readonly SortedRangeIndex<UInt128> _privateNetworkV6 = BuildIndex(
[ "fc00::/7",
new CidrFirewallEntry("fc00::/7"), "fe80::/10"
new CidrFirewallEntry("fe80::/10") );
];
public static bool IsPrivateNetworkV4(this IPAddress ip) private static SortedRangeIndex<UInt128> BuildIndex(params ReadOnlySpan<string> cidrs)
{ {
for (var i = 0; i < _privateNetworkV4.Length; i++) var ranges = new SortedRangeIndex<UInt128>.Range[cidrs.Length];
for (var i = 0; i < cidrs.Length; i++)
{ {
if (_privateNetworkV4[i].IsBlocked(ip)) if (!IPAddressUtility.TryParseCidrRange(cidrs[i], out var min, out var max))
{ {
return true; throw new ArgumentException($"Invalid reserved-network CIDR \"{cidrs[i]}\"");
}
} }
return false; ranges[i] = new SortedRangeIndex<UInt128>.Range(min, max);
} }
public static bool IsPrivateNetworkV6(this IPAddress ip) => Array.Sort(ranges, SortedRangeIndex<UInt128>.ByMin);
_privateNetworkV6[0].IsBlocked(ip) || return SortedRangeIndex<UInt128>.Build(ranges);
_privateNetworkV6[1].IsBlocked(ip); }
public static bool IsPrivateNetworkV4(this IPAddress ip) => _privateNetworkV4.Contains(ip.ToUInt128());
public static bool IsPrivateNetworkV6(this IPAddress ip) => _privateNetworkV6.Contains(ip.ToUInt128());
} }

View file

@ -108,45 +108,6 @@ public static partial class Utility
} }
} }
// Converts an IPAddress to a UInt128 in IPv6 format
public static UInt128 ToUInt128(this IPAddress ip)
{
if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6)
{
Span<byte> integer = stackalloc byte[4];
return !ip.TryWriteBytes(integer, out _)
? (UInt128)0
: new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer));
}
Span<byte> bytes = stackalloc byte[16];
if (!ip.TryWriteBytes(bytes, out _))
{
return 0;
}
var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]);
var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8));
return new UInt128(high, low);
}
// Converts a UInt128 in IPv6 format to an IPAddress
public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false)
{
// IPv4 mapped IPv6 address
if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL)
{
var newAddress = IPAddress.HostToNetworkOrder((int)value);
return new IPAddress(unchecked((uint)newAddress));
}
Span<byte> bytes = stackalloc byte[16]; // 128 bits for IPv6 address
((IBinaryInteger<UInt128>)value).WriteBigEndian(bytes);
return new IPAddress(bytes);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static UInt128 CreateCidrAddress(ReadOnlySpan<byte> bytes, int prefixLength, bool isMax) public static UInt128 CreateCidrAddress(ReadOnlySpan<byte> bytes, int prefixLength, bool isMax)
{ {
@ -1089,28 +1050,16 @@ 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)
{ {
if (serializableKey) var deleted = serializableKey
{ ? ((ISerializable)key).Deleted
if (key == null || ((ISerializable)key).Deleted) : value == null || ((ISerializable)value).Deleted;
{
queue.Enqueue(key);
}
}
else
{
if (value == null || ((ISerializable)value).Deleted)
{
queue.Enqueue(key);
}
}
}
while (queue.Count > 0) if (deleted)
{ {
dictionary.Remove(queue.Dequeue()); dictionary.Remove(key);
}
} }
dictionary.TrimExcess(); dictionary.TrimExcess();

View file

@ -93,6 +93,21 @@ 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;
@ -287,6 +302,10 @@ 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

@ -1,4 +1,4 @@
using System; using System;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Threading; using System.Threading;
@ -61,6 +61,15 @@ internal static class TestServerInitializer
AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]);
SkillsInfo.Configure(); SkillsInfo.Configure();
// Seed the loop clock as Main.cs does before the Configure sweep; otherwise Core.Now is
// DateTime.MinValue for the whole test host.
Core._now = DateTime.UtcNow;
// Timer wheel must exist before NetState.Configure(), which schedules a recurring
// sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs
// before AssemblyHandler.Invoke("Configure")).
Timer.Init(0);
Server.Network.NetState.Configure(); Server.Network.NetState.Configure();
TestMapDefinitions.ConfigureTestMapDefinitions(); TestMapDefinitions.ConfigureTestMapDefinitions();
@ -91,8 +100,10 @@ internal static class TestServerInitializer
} }
World.Configure(); World.Configure();
Timer.Init(0); // 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

@ -0,0 +1,74 @@
using System;
using Server.Accounting;
using Server.Accounting.Security;
using Xunit;
namespace Server.Tests.Accounting;
[Collection("Sequential UOContent Tests")]
public class AccountPasswordTests : IDisposable
{
private const string Password = "hunter2";
// CurrentAlgorithm is process-wide state shared with the rest of the collection.
private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm;
public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm;
[Theory]
[InlineData(PasswordProtectionAlgorithm.SHA1)]
[InlineData(PasswordProtectionAlgorithm.SHA2)]
[InlineData(PasswordProtectionAlgorithm.PBKDF2)]
[InlineData(PasswordProtectionAlgorithm.Argon2)]
public void NewAccount_CanLogIn(PasswordProtectionAlgorithm algorithm)
{
AccountSecurity.CurrentAlgorithm = algorithm;
var account = new Account($"new-{algorithm}-user", Password);
Assert.Equal(algorithm, account.PasswordAlgorithm);
Assert.True(account.CheckPassword(Password));
Assert.False(account.CheckPassword("wrong-password"));
}
// SetPassword assigns PasswordAlgorithm before deriving the phrase from it. Reversed, the hash
// is salted by the outgoing algorithm's rule but stored under the incoming one, which verifies
// once and then never again.
[Theory]
[InlineData(PasswordProtectionAlgorithm.SHA1)]
[InlineData(PasswordProtectionAlgorithm.SHA2)]
[InlineData(PasswordProtectionAlgorithm.PBKDF2)]
public void UpgradingAlgorithm_DoesNotLockTheAccountOut(PasswordProtectionAlgorithm from)
{
AccountSecurity.CurrentAlgorithm = from;
var account = new Account($"upgrade-{from}-user", Password);
Assert.True(account.CheckPassword(Password));
AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
Assert.True(account.CheckPassword(Password));
Assert.Equal(PasswordProtectionAlgorithm.Argon2, account.PasswordAlgorithm);
// Must verify against what the rehash wrote.
Assert.True(account.CheckPassword(Password));
Assert.False(account.CheckPassword("wrong-password"));
}
[Fact]
public void StaleArgon2Parameters_AreRehashedOnLogin()
{
AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
var account = new Account("stale-params-user", Password);
// The shipping default before this change: Argon2i, m=8192, t=3, p=1.
account.Password =
"$argon2i$v=19$m=8192,t=3,p=1$LD1XJz7P3wQmIJ+Tu6ScgA$NO5hBABsHQ172C5nDO2X4gWnB4jDef3x6WhLdVE2LFw";
Assert.True(account.CheckPassword(Password));
Assert.StartsWith("$argon2id$v=19$m=16384,t=1,p=1$", account.Password);
// Already current: verifying again must not rewrite the hash.
var afterFirst = account.Password;
Assert.True(account.CheckPassword(Password));
Assert.Equal(afterFirst, account.Password);
}
}

View file

@ -0,0 +1,229 @@
using System;
using System.Threading;
using Server.Accounting;
using Server.Accounting.Security;
using Xunit;
namespace Server.Tests.Accounting;
[Collection("Sequential UOContent Tests")]
public class PasswordWorkerTests : IDisposable
{
private const string Password = "hunter2";
private readonly PasswordProtectionAlgorithm _originalAlgorithm = AccountSecurity.CurrentAlgorithm;
public PasswordWorkerTests() => AccountSecurity.CurrentAlgorithm = PasswordProtectionAlgorithm.Argon2;
public void Dispose() => AccountSecurity.CurrentAlgorithm = _originalAlgorithm;
private static Account CreateAccount(string username) =>
Accounts.GetAccount(username) as Account ?? new Account(username, Password);
/// <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,4 +74,89 @@ 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

@ -1,80 +0,0 @@
using System.Linq;
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
[Collection("Sequential UOContent Tests")]
public class CagTreeBuilderTests
{
[Fact]
public void BuildTree_creates_nested_categories_and_resolves_types()
{
var index = new ObjectIndexFile
{
Objects =
[
new ObjectIndexEntry
{
Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords",
Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0
}
]
};
var root = CAGLoader.BuildTree(index);
var items = Assert.IsType<CAGCategory>(FindChild(root, "Items"));
var weapons = Assert.IsType<CAGCategory>(FindChild(items, "Weapons"));
var swords = Assert.IsType<CAGCategory>(FindChild(weapons, "Swords"));
var leaf = Assert.IsType<CAGObject>(swords.Nodes[0]);
Assert.Equal(typeof(Katana), leaf.Type);
Assert.Equal(0x13FF, leaf.ItemID);
}
[Fact]
public void BuildTree_keeps_multiple_objects_in_one_category()
{
var index = new ObjectIndexFile
{
Objects =
[
new ObjectIndexEntry { Type = "Katana", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x13FF, Hue = 0 },
new ObjectIndexEntry { Type = "Longsword", Entity = "item", Category = "Items.Weapons.Swords", Chunk = "items.weapons.swords", ItemID = 0x0F5E, Hue = 0 }
]
};
var root = CAGLoader.BuildTree(index);
var swords = (CAGCategory)FindNestedCategory(root, "Items", "Weapons", "Swords");
var leafTypes = swords.Nodes.OfType<CAGObject>().Select(o => o.Type).ToList();
Assert.Contains(typeof(Katana), leafTypes);
Assert.Contains(typeof(Server.Items.Longsword), leafTypes);
}
private static CAGNode FindChild(CAGCategory parent, string title)
{
foreach (var node in parent.Nodes)
{
if (node.Title == title)
{
return node;
}
}
throw new Xunit.Sdk.XunitException($"No child '{title}'.");
}
private static CAGNode FindNestedCategory(CAGCategory root, params string[] titles)
{
CAGNode current = root;
foreach (var title in titles)
{
current = FindChild((CAGCategory)current, title);
}
return current;
}
}

View file

@ -1,44 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
public class CategorizationSyncTests
{
private static CAGJson Cat(string category, params System.Type[] types) =>
new()
{
Category = category,
Objects = types.Select(t => new CAGObject { Type = t }).ToArray()
};
[Fact]
public void Reconcile_appends_missing_types_to_uncategorized()
{
var categorization = new List<CAGJson> { Cat("Items.Weapons.Swords", typeof(Katana)) };
var discovered = new List<System.Type> { typeof(Katana), typeof(Runebook) };
var (updated, report) = CategorizationSync.Reconcile(categorization, discovered);
Assert.Contains("Runebook", report.Appended);
Assert.Empty(report.Orphaned);
var uncategorized = Assert.Single(updated, c => c.Category == "Items.Uncategorized");
Assert.Contains(uncategorized.Objects, o => o.Type == typeof(Runebook));
}
[Fact]
public void Reconcile_reports_orphans_not_in_discovered()
{
var categorization = new List<CAGJson> { Cat("Items.Weapons.Swords", typeof(Katana)) };
var discovered = new List<System.Type> { typeof(Runebook) };
var (_, report) = CategorizationSync.Reconcile(categorization, discovered);
Assert.Contains("Katana", report.Orphaned);
Assert.Contains("Runebook", report.Appended);
}
}

View file

@ -1,35 +0,0 @@
using System.Collections.Generic;
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
public class ObjectCacheBuilderTests
{
[Fact]
public void Build_produces_index_row_and_detail_chunk()
{
var extracted = new ExtractedObject(
typeof(Runebook),
"item",
"Items.Skill Items.Magical",
new LeanMetadata(8901, 0x461, "runebook", 1041267),
[new CtorDoc()],
[new PropertyDoc { Name = "Hue", Type = "int" }],
[new OplLine { Cliloc = 1041267 }],
"Item"
);
var (index, chunks) = ObjectCacheBuilder.Build([extracted], "2026-07-19T00:00:00Z");
var row = Assert.Single(index.Objects);
Assert.Equal("Runebook", row.Type);
Assert.Equal("items.skill-items.magical", row.Chunk);
Assert.Equal(8901, row.ItemID);
var chunk = Assert.Contains("items.skill-items.magical", chunks);
Assert.Contains("Runebook", chunk.Keys);
Assert.Equal("Item", chunk["Runebook"].BaseType);
}
}

View file

@ -1,32 +0,0 @@
using System;
using System.Collections.Generic;
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
[Collection("Sequential UOContent Tests")]
public class ObjectCacheGeneratorTests
{
[Fact]
public void Generate_builds_index_and_chunks_from_extracted_objects()
{
var discovered = new List<Type> { typeof(Runebook), typeof(Katana) };
var categorization = new List<CAGJson>(); // empty -> both land in Items.Uncategorized
var result = ObjectCacheGenerator.Generate(categorization, discovered);
Assert.Equal(2, result.Index.Objects.Count);
Assert.NotEmpty(result.Chunks);
var runebook = Assert.Single(result.Index.Objects, o => o.Type == "Runebook");
Assert.True(runebook.ItemID > 0);
Assert.Equal(1041267, runebook.Cliloc);
Assert.Equal("items.uncategorized", runebook.Chunk);
Assert.Contains("Runebook", result.Report.Appended);
Assert.Contains("items.uncategorized", result.Chunks.Keys);
Assert.Contains("Runebook", result.Chunks["items.uncategorized"].Keys);
}
}

View file

@ -1,19 +0,0 @@
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
[Collection("Sequential UOContent Tests")]
public class ObjectDiscoveryTests
{
[Fact]
public void Discover_includes_concrete_constructibles_and_excludes_abstract()
{
var types = ObjectIntrospection.DiscoverConstructibleTypes();
Assert.Contains(typeof(Katana), types);
Assert.Contains(typeof(Runebook), types);
Assert.DoesNotContain(typeof(BaseWeapon), types); // abstract
}
}

View file

@ -1,52 +0,0 @@
using System.IO;
using Server.Commands;
using Server.Json;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
public class ObjectIndexSerializationTests
{
[Fact]
public void ObjectIndexFile_round_trips_through_json()
{
var index = new ObjectIndexFile
{
GeneratedUtc = "2026-07-19T00:00:00Z",
Objects =
[
new ObjectIndexEntry
{
Type = "Katana",
Entity = "item",
Category = "Items.Weapons.Swords",
Chunk = "items.weapons.swords",
ItemID = 8901,
Hue = 0x461,
Name = "katana",
Cliloc = 1041267
}
]
};
var tempPath = Path.GetTempFileName();
try
{
JsonConfig.Serialize(tempPath, index);
var roundTripped = JsonConfig.Deserialize<ObjectIndexFile>(tempPath);
Assert.NotNull(roundTripped);
var entry = Assert.Single(roundTripped.Objects);
Assert.Equal("Katana", entry.Type);
Assert.Equal("items.weapons.swords", entry.Chunk);
Assert.Equal(8901, entry.ItemID);
Assert.Equal(0x461, entry.Hue);
Assert.Equal(1041267, entry.Cliloc);
}
finally
{
File.Delete(tempPath);
}
}
}

View file

@ -1,23 +0,0 @@
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
[Collection("Sequential UOContent Tests")]
public class ObjectIntrospectionCtorsTests
{
[Fact]
public void ExtractCtors_lists_both_constructible_runebook_overloads()
{
// Runebook has [Constructible] Runebook() and [Constructible] Runebook(int maxCharges).
var ctors = ObjectIntrospection.ExtractCtors(typeof(Runebook));
Assert.Equal(2, ctors.Count);
Assert.Contains(ctors, c => c.Parameters.Count == 0);
var parameterized = Assert.Single(ctors, c => c.Parameters.Count == 1);
Assert.Equal("maxCharges", parameterized.Parameters[0].Name);
Assert.Equal("int", parameterized.Parameters[0].Type);
}
}

View file

@ -1,31 +0,0 @@
using Server.Commands;
using Server.Items;
using Server.Tests;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
[Collection("Sequential UOContent Tests")]
public class ObjectIntrospectionLeanTests
{
[SkippableFact]
public void ExtractLean_reads_item_id_from_a_weapon()
{
// Requires client TileData: ExtractLean clamps itemID > TileData.MaxItemValue to 1, and
// MaxItemValue is 0 when tiledata.mul is absent (CI), so the real 0x13FF only survives with data.
TileDataRequirement.SkipIfMissing();
// Katana ctor is base(0x13FF) — era-independent.
var lean = ObjectIntrospection.ExtractLean(typeof(Katana));
Assert.Equal(0x13FF, lean.ItemID);
}
[Fact]
public void ExtractLean_reads_hue_and_cliloc_from_a_runebook()
{
// Runebook sets Hue = 0x461 and LabelNumber 1041267 regardless of era.
var lean = ObjectIntrospection.ExtractLean(typeof(Runebook));
Assert.Equal(0x461, lean.Hue);
Assert.Equal(1041267, lean.Cliloc);
}
}

View file

@ -1,17 +0,0 @@
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
[Collection("Sequential UOContent Tests")]
public class ObjectIntrospectionOplTests
{
[Fact]
public void ExtractOpl_captures_the_runebook_name_cliloc()
{
// Runebook.LabelNumber is 1041267 ("runebook") — it appears as an OPL line.
var opl = ObjectIntrospection.ExtractOpl(typeof(Runebook));
Assert.Contains(opl, line => line.Cliloc == 1041267);
}
}

View file

@ -1,22 +0,0 @@
using Server.Commands;
using Server.Items;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
[Collection("Sequential UOContent Tests")]
public class ObjectIntrospectionPropertiesTests
{
[Fact]
public void ExtractProperties_includes_inherited_item_command_properties()
{
var props = ObjectIntrospection.ExtractProperties(typeof(Runebook));
var hue = Assert.Single(props, p => p.Name == "Hue");
Assert.Equal("int", hue.Type);
var lootType = Assert.Single(props, p => p.Name == "LootType");
Assert.NotNull(lootType.EnumValues);
Assert.Contains("Blessed", lootType.EnumValues);
}
}

View file

@ -1,29 +0,0 @@
using System;
using Server.Commands;
using Xunit;
namespace UOContent.Tests.Commands.Objects;
public class ObjectNamingTests
{
[Theory]
[InlineData("Items.Skill Items.Magical", "items.skill-items.magical")]
[InlineData("Items.Weapons.Swords", "items.weapons.swords")]
[InlineData("Mobiles.Uncategorized", "mobiles.uncategorized")]
public void ChunkKey_lowercases_and_replaces_spaces(string category, string expected)
{
Assert.Equal(expected, ObjectNaming.ChunkKey(category));
}
[Theory]
[InlineData(typeof(int), "int")]
[InlineData(typeof(bool), "bool")]
[InlineData(typeof(string), "string")]
[InlineData(typeof(double), "double")]
[InlineData(typeof(int?), "int?")]
[InlineData(typeof(Server.Items.WeaponQuality), "WeaponQuality")]
public void FriendlyTypeName_maps_primitives_and_keeps_enum_names(Type t, string expected)
{
Assert.Equal(expected, ObjectNaming.FriendlyTypeName(t));
}
}

View file

@ -0,0 +1,17 @@
using Server.Engines.AdvancedSearch;
using Xunit;
namespace UOContent.Tests;
public class AdvancedSearchPagingTests
{
[Theory]
[InlineData(20, 0, 18, 18)] // full first page
[InlineData(20, 18, 18, 2)] // partial last page -> 2 visible (bug rendered 0 in descending)
[InlineData(5, 0, 18, 5)]
[InlineData(0, 0, 18, 0)]
public void VisibleCount_IsCorrect(int total, int from, int max, int expected)
{
Assert.Equal(expected, AdvancedSearchGump.VisibleCount(total, from, max));
}
}

View file

@ -0,0 +1,27 @@
using Server;
using Server.Engines.AdvancedSearch;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class AdvancedSearchTypesTests
{
[Fact]
public void CompareValues_Poison_ReferenceTypeParsedViaTypes()
{
PoisonKinds.Configure(); // idempotent; registers Lesser..Lethal now that Core.Expansion is set
// Poison is a reference type implementing ISpanParsable; it can't use the compile-time span
// path and routes through the shared Server.Types converter. Poison.Parse returns the
// registered singleton, so "= Lethal" is a reference-equality match — this is the case that
// previously compared a Poison against the raw string and always failed.
var prop = Poison.Lethal;
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lethal", "="));
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "Lesser", "="));
var ex = Record.Exception(() =>
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Poison), prop, "notapoison", "=")));
Assert.Null(ex);
}
}

View file

@ -0,0 +1,119 @@
using System;
using Server;
using Server.Engines.AdvancedSearch;
using Xunit;
namespace UOContent.Tests;
public class AdvancedSearchUtilitiesTests
{
[Theory]
[InlineData("abc")] // not a number -> was FormatException
[InlineData("99999999999")] // overflows int -> was OverflowException
[InlineData("0xZZ")] // bad hex -> was FormatException
public void CompareValues_BadNumeric_ReturnsFalse_DoesNotThrow(string value)
{
var ex = Record.Exception(() =>
{
var result = AdvancedSearchUtilities.CompareValues(typeof(int), 5, value, ">");
Assert.False(result);
});
Assert.Null(ex);
}
[Theory]
[InlineData("Bogus")] // not a member -> was ArgumentException
[InlineData("onehandedxyz")] // not a member, even case-insensitively -> was ArgumentException
public void CompareValues_BadEnum_ReturnsFalse_DoesNotThrow(string value)
{
var ex = Record.Exception(() =>
{
var result = AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, value, "=");
Assert.False(result);
});
Assert.Null(ex);
}
[Fact]
public void CompareValues_ValidEnum_IgnoresCase()
{
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Layer), (byte)Layer.OneHanded, "onehanded", "="));
}
[Theory]
// leaf value is "T"/"F"; evalLeaf returns leaf=="T"
[InlineData("T", true)]
[InlineData("F", false)]
[InlineData("F@F|T", true)] // (F&&F)||T = T (buggy code gave F&&(F||T)=F)
[InlineData("T|F@F", true)] // T||(F&&F) = T (buggy code gave (T||F)&&F=F)
[InlineData("T@F", false)]
[InlineData("T@T", true)]
[InlineData("F|F", false)]
public void EvaluateBoolean_Precedence(string expr, bool expected)
{
// State is unused here; the leaf evaluator just checks the span equals "T".
var result = AdvancedSearchUtilities.EvaluateBoolean(expr, 0, static (_, leaf) => leaf.SequenceEqual("T"));
Assert.Equal(expected, result);
}
[Fact]
public void CompareValues_ReferenceType_EqualityByString_NoThrow()
{
// A reference-typed property (e.g. RootParent name-ish) compared with "=" should not throw,
// and ordering operators must return false rather than throwing.
var ex = Record.Exception(() =>
{
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(object), new object(), "whatever", ">"));
});
Assert.Null(ex);
}
[Fact]
public void CompareValues_TimeSpan_ParsesViaSpanParsable()
{
// TimeSpan is not IConvertible, so the old Convert.ChangeType fallback threw and silently
// returned no-match. ISpanParsable<TimeSpan> parses it correctly.
var prop = TimeSpan.FromMinutes(5);
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:05:00", "="));
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:10:00", "="));
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), prop, "00:01:00", ">"));
}
[Fact]
public void CompareValues_TimeSpan_BadInput_ReturnsFalse_NoThrow()
{
var ex = Record.Exception(() =>
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(TimeSpan), TimeSpan.Zero, "notaspan", "=")));
Assert.Null(ex);
}
[Fact]
public void CompareValues_Guid_ValueTypeParsedViaTypes()
{
// Guid is a value type not named by the hot paths; it's parsed via Types (IParsable) and
// compared by value.
var g = Guid.Parse("00000000-0000-0000-0000-000000000001");
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000001", "="));
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(Guid), g, "00000000-0000-0000-0000-000000000002", "="));
}
// A reference type with a legacy RunUO-style static Parse(string) and NO IParsable<> interface —
// the Faction/Town shape. Types must still discover its Parse by reflection.
private sealed class LegacyParseType
{
public string Value { get; private init; }
public static LegacyParseType Parse(string s) => new() { Value = s };
public override bool Equals(object obj) => obj is LegacyParseType o && o.Value == Value;
public override int GetHashCode() => Value?.GetHashCode() ?? 0;
}
[Fact]
public void CompareValues_LegacyParseString_ParsedViaTypes()
{
// Pre-IParsable types (only a static Parse(string)) must still be searchable: Types binds the
// legacy Parse by reflection, so we compare against a real parsed instance, not the raw text.
var prop = LegacyParseType.Parse("alpha");
Assert.True(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "alpha", "="));
Assert.False(AdvancedSearchUtilities.CompareValues(typeof(LegacyParseType), prop, "beta", "="));
}
}

View file

@ -0,0 +1,95 @@
using System;
using System.Collections.Concurrent;
using Server;
using Server.Engines.AdvancedSearch;
using Server.Items;
using Server.Tests;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class AdvancedSearchWorkerTests
{
// An item whose property test path will throw when evaluated.
private sealed class ThrowingItem : Item
{
public ThrowingItem() : base(0x1) { }
public ThrowingItem(Serial s) : base(s) { }
public string Boom => throw new InvalidOperationException("boom");
}
[Fact]
public void Worker_FilterThrows_DoesNotEscape_ReturnsNoMatch()
{
var worker = new AdvancedSearchThreadWorker();
var results = new ConcurrentQueue<AdvancedSearchResult>();
var ignore = new ConcurrentQueue<IEntity>();
var filter = new AdvancedSearchFilter
{
FilterPropertyTest = true,
PropertyTest = "Boom=1", // reflection GetValue -> throws
};
var item = new ThrowingItem();
try
{
worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore);
worker.Push(item);
worker.Sleep(); // drains; must not crash the test process
Assert.Empty(results);
}
finally
{
item.Delete();
worker.Exit();
}
}
[Fact]
public void Worker_DeletedEntity_IsSkipped()
{
var worker = new AdvancedSearchThreadWorker();
var results = new ConcurrentQueue<AdvancedSearchResult>();
var ignore = new ConcurrentQueue<IEntity>();
var filter = new AdvancedSearchFilter(); // no filters -> everything matches
var item = new Item(0x1);
item.Delete();
try
{
worker.Wake(new WorldLocation(Point3D.Zero, Map.Felucca), filter, results, ignore);
worker.Push(item);
worker.Sleep();
Assert.Empty(results);
}
finally
{
worker.Exit();
}
}
[Fact]
public void DoSearch_IsGuarded_AgainstReentry()
{
// White-box: flip the guard, assert a second entry is rejected, then clear.
// _searchInProgress is process-global static state; release it in finally so a
// failed assert here can't leak the guard into other tests.
Assert.False(AdvancedSearchGump.IsSearchInProgress);
Assert.True(AdvancedSearchGump.TryBeginSearch()); // acquires
try
{
Assert.False(AdvancedSearchGump.TryBeginSearch()); // rejected
}
finally
{
AdvancedSearchGump.EndSearch(); // releases
}
Assert.False(AdvancedSearchGump.IsSearchInProgress);
}
}

View file

@ -0,0 +1,100 @@
using System.Collections.Generic;
using Server;
using Server.Factions;
using Xunit;
namespace UOContent.Tests;
/// <summary>
/// PlayerState.Rank is read from GetProperties, so it must stay a plain field read. These pin what
/// that requires: the rank is never null, and it is correct without anyone having read it first.
/// </summary>
[Collection("Sequential UOContent Tests")]
public class FactionRankTests
{
// The faction ctor builds its own Definition, so no world state is needed.
private static Faction NewFaction() => new CouncilOfMages();
private static PlayerState AddMember(Faction faction, List<PlayerState> owner)
{
var state = new PlayerState(new Mobile(), faction, owner);
owner.Add(state);
return state;
}
[Fact]
public void Rank_IsPopulatedBeforeAnythingReadsIt()
{
var faction = NewFaction();
var state = new PlayerState(new Mobile(), faction, []);
// Nothing recomputes on read, so the ctor must leave a usable value or Rank.Title NREs.
Assert.NotNull(state.Rank);
Assert.NotNull(state.Rank.Title);
}
[Fact]
public void Rank_IsTheLowestRank_ForAnUnrankedMember()
{
var faction = NewFaction();
var owner = new List<PlayerState>();
var a = AddMember(faction, owner);
var b = AddMember(faction, owner);
a.UpdateRank();
b.UpdateRank();
var lowest = faction.Definition.Ranks[^1];
Assert.Equal(lowest.Rank, a.Rank.Rank);
Assert.Equal(lowest.Rank, b.Rank.Rank);
}
[Fact]
public void SettingRankIndex_UpdatesRankWithoutAnyoneReadingIt()
{
var faction = NewFaction();
var owner = new List<PlayerState>();
var top = AddMember(faction, owner);
var bottom = AddMember(faction, owner);
faction.ZeroRankOffset = 2;
top.RankIndex = 0;
bottom.RankIndex = 1;
// No read triggered these, yet the ordering is reflected.
Assert.True(top.Rank.Rank > bottom.Rank.Rank);
}
[Fact]
public void ReadingRank_IsStableAndSideEffectFree()
{
var faction = NewFaction();
var owner = new List<PlayerState>();
var state = AddMember(faction, owner);
faction.ZeroRankOffset = 1;
state.RankIndex = 0;
var first = state.Rank;
var second = state.Rank;
Assert.Same(first, second);
}
[Fact]
public void RankIndexOutOfSyncWithZeroRankOffset_StillResolvesARank()
{
var faction = NewFaction();
var owner = new List<PlayerState>();
var a = AddMember(faction, owner);
AddMember(faction, owner);
// A negative percent used to match no rank at all, leaving Rank null.
faction.ZeroRankOffset = 1;
a.RankIndex = 5;
Assert.NotNull(a.Rank);
}
}

View file

@ -6,6 +6,8 @@ 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

@ -0,0 +1,40 @@
using Server.Gumps;
namespace Server.Tests.Gumps;
public sealed class EmptyLegacyTestGump : Gump
{
public bool HasVisualElementsForTest => HasVisualElements;
public EmptyLegacyTestGump() : base(0, 0)
{
}
}
public sealed class EmptyDynamicTestGump : DynamicGump
{
public bool HasVisualElementsForTest => HasVisualElements;
public EmptyDynamicTestGump() : base(0, 0)
{
}
protected override void BuildLayout(ref DynamicGumpBuilder builder)
{
builder.AddPage();
}
}
public sealed class EmptyStaticTestGump : StaticGump<EmptyStaticTestGump>
{
public bool HasVisualElementsForTest => HasVisualElements;
public EmptyStaticTestGump() : base(0, 0)
{
}
protected override void BuildLayout(ref StaticGumpBuilder builder)
{
builder.SetNoClose();
}
}

View file

@ -4,6 +4,8 @@ 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,6 +4,8 @@ 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,6 +73,32 @@ 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

@ -0,0 +1,111 @@
using Server;
using Server.Items;
using Server.Mobiles;
using Server.Tests;
using Xunit;
namespace UOContent.Tests;
[Collection("Sequential UOContent Tests")]
public class TreasureMapChestLiftTests
{
// Coordinates chosen to avoid overlap with Tracking (1000-4000, 1000-4000) and
// DetectHidden (1000-2400, 500) test areas.
[Fact]
public void PartialLift_MarksSplitRemainderAsLifted()
{
using var rng = new PredictableRandom(10); // RandomDouble() = 0.5, no spawn roll fires
var map = Map.Felucca;
var location = new Point3D(5000, 600, 0);
var player = CreatePlayerMobile(map, location);
var chest = new TreasureMapChest(1);
try
{
chest.MoveToWorld(location, map);
chest.Locked = false;
var gold = FindGold(chest, null);
Assert.NotNull(gold);
player.Lift(gold, 1, out var rejected, out _);
Assert.False(rejected);
// The stack split re-adds the remainder as a brand-new item. It must count as
// already lifted, otherwise every 1-coin pull grants a fresh guardian spawn roll.
var remainder = FindGold(chest, gold);
Assert.NotNull(remainder);
Assert.Contains(remainder, chest.Lifted);
Assert.Contains(gold, chest.Lifted);
}
finally
{
player.Holding?.Delete();
player.Delete();
chest.Delete();
}
}
[Fact]
public void ItemAddedAfterFill_IsMarkedLifted()
{
using var rng = new PredictableRandom(10);
var chest = new TreasureMapChest(1);
var packed = new Gold(500);
try
{
// Anything entering the chest after the initial fill (packed-back gold, split
// remainders, GM drops) was never part of the original loot and must not
// grant spawn rolls when lifted back out.
chest.DropItem(packed);
Assert.Contains(packed, chest.Lifted);
}
finally
{
chest.Delete();
}
}
[Fact]
public void OriginalFillLoot_IsNotMarkedLifted()
{
using var rng = new PredictableRandom(10);
var chest = new TreasureMapChest(1);
try
{
// The original loot must stay roll-eligible for its first lift.
Assert.True(chest.Lifted == null || chest.Lifted.Count == 0);
}
finally
{
chest.Delete();
}
}
private static Gold FindGold(TreasureMapChest chest, Gold except)
{
var items = chest.Items;
for (var i = 0; i < items.Count; i++)
{
if (items[i] is Gold gold && gold != except)
{
return gold;
}
}
return null;
}
private static PlayerMobile CreatePlayerMobile(Map map, Point3D location)
{
var mobile = new PlayerMobile(World.NewMobile);
mobile.DefaultMobileInit();
mobile.MoveToWorld(location, map);
return mobile;
}
}

View file

@ -0,0 +1,211 @@
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, false, 1, 2); ai.WalkMobileRange(target, 1, 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, false, 1); ai.MoveTo(target, 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, true, 1); ai.MoveTo(target, 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, false, 1); ai.MoveTo(target, 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, false, 1); ai.MoveTo(target, 1);
if (bc.Location != idleStart) if (bc.Location != idleStart)
{ {
stayedIdle = false; stayedIdle = false;

View file

@ -0,0 +1,96 @@
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

@ -0,0 +1,137 @@
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

@ -0,0 +1,219 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles.AI;
// Pins the CurrentMoveSpeed classification (verbatim active/passive maps to the matching
// move value; bespoke stays fused), SetSpeed's one-clock guarantee, and the v22 tail.
[Collection("Sequential UOContent Tests")]
public class MoveSpeedTests : IDisposable
{
// Delete spawned stubs so they don't linger in the shared static World.
private readonly List<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);
}
}

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