Commit graph

50 commits

Author SHA1 Message Date
Kamron Batman
9e347e9364
fix(login): stop the worker on shutdown, do not pretend to finish its work
Shutdown() drained pending writes and applied them. That accomplished
nothing: no save runs on shutdown -- World.Save() is reachable only from the
autosave timer and the console command, and HandleClosed merely waits for an
in-progress write before Environment.Exit(0) -- so an applied write lands in
an Account that is immediately discarded. It was justified as becoming
correct once a shutdown save exists, which is building for a fix that does
not.

It also called Core.LoopContext.ExecuteTasks(), which is not a subscriber's
to call. Pumping the shared context from inside a shutdown handler runs other
subscribers' posted work at an arbitrary point in the event order. That drain
belongs in the core, before the events, and is recorded as such in the
follow-up handoff along with the ordering it requires.

What is left is stopping the thread, which is the same on both paths, so
shutdown and crash now share one implementation.
2026-08-08 22:57:13 -07:00
Kamron Batman
e2295ec611
Apply suggestion from @kamronbatman 2026-08-08 22:51:00 -07:00
Kamron Batman
9434743a35
docs: sweep comments to match what the code now does
Several comments still described an Argon2-only worker: the class summary,
the thread name, the log message, and the dev-docs entry. The worker runs
whichever protection an account stores.

Trimmed the rest to the fact a reader cannot recover from the code, and
dropped the narration around it.

Adds a sixth worker rule to the threading model, which is the one this branch
actually learned: everything a worker calls must itself be safe off-thread,
and a process-wide singleton is not automatically safe. HashAlgorithm carries
the running digest across HashCore/HashFinal, and Utility's RNG is a shared
System.Random and game state besides. Both were reasons the worker had been
narrowed to Argon2, and both were better fixed at the source.
2026-08-08 22:47:54 -07:00
Kamron Batman
b95be044bd
docs: say why a cheap digest still earns its thread hop
The comment conceded a needless hop for MD5 and SHA1. It is not needless:
AccountSecurity.Configure refuses anything below SHA2 as the configured
algorithm, so those only ever appear as a stored hash awaiting migration.
That makes NeedsPasswordUpgrade true, so the job carries the upgrade hash and
the microsecond digest is never the whole job.
2026-08-08 22:43:48 -07:00
Kamron Batman
91c8873b7a
refactor(accounts): make every password protection thread safe, drop the Argon2 carve-out
The worker was Argon2-only and kept its own protection instance. Both are now
unnecessary, but not for the reason the code gave.

CreateIsolated() was justified by the RNG, which was wrong. Argon2's Verify
is static-backed and stackalloc throughout, and the salt RNG is a stateless
syscall wrapper -- neither has state to race over. The real blocker was
HashAlgorithmPasswordProtection, which retains a HashAlgorithm carrying the
running digest across HashCore/HashFinal, shared through process-wide
singletons. Two threads there corrupt each other.

That is fixed at the source: hashing now goes through the one-shot static
APIs, which have no such state, allocate nothing, and produce identical
bytes. Literal digests are pinned in a test first, because these are compared
as strings against every account database -- any drift would lock out every
SHA and MD5 account at once.

PBKDF2 drew its iteration count from Utility.RandomMinMax, a shared
System.Random that is both thread-unsafe and game state. It now uses the
cryptographic RNG, matching the salt beside it.

With all three safe, the worker no longer needs to know which algorithm it is
running, and the dispatch conditions collapse to "is off-loop available". A
cheap digest now pays a thread hop it does not need, which costs login
latency we have already decided not to care about, and saves loop time we do.
2026-08-08 22:36:55 -07:00
Kamron Batman
345c2581a9
refactor(login): drop the password write sequence, ordering is already total
The sequence guarded against a reordering that cannot happen. Dispatch runs
on the game loop, so enqueue order is dispatch order; one worker drains the
queue FIFO and processes serially; results post to the loop context in that
same order and are drained in it. Last dispatched is last applied.

The case it was written for -- two changes dispatched before either landed,
second silently dropped -- was caused by the hash-comparison guard it
replaced, not by concurrency. Removing the guard fixes it; adding a more
elaborate one was the wrong move.

Checked every inline SetPassword that bypasses the queue: the constructor and
XML import cannot have a job in flight, CheckPassword's rehash is only
reached by non-Argon2 accounts which never queue, and the two fallbacks in
PasswordWorker.SetPassword only run when nothing is queued at all or the
queue holds 4096. So there is no reachable interleave to guard.

The single worker is now load-bearing for ordering as well as for cache,
scheduling and memory. Noted where it matters, and WritesApplyInDispatchOrder
pins it through the real queue so a second worker would fail a test rather
than silently reorder writes.
2026-08-08 22:10:03 -07:00
Kamron Batman
2d9d83b2fd
fix(login): finish pending password writes on a normal shutdown
Exit() was never wired, so it was dead code either way. Wiring it correctly
depends on which teardown path is running.

On a normal shutdown EventSink.Shutdown fires on the game thread after the
loop has stopped, which is the last chance pending work gets: results the
worker already posted are sitting on a loop context nothing will pump again.
So the thread is stopped, the context drained once, and queued writes are
computed and applied in place. Verifies are dropped instead -- they only
decide a login, and every connection is closing.

On a crash there is no usable game thread, so nothing may be applied and the
thread is simply stopped. Subscribed separately because HandleClosed skips
InvokeShutdown when _crashed is set, which would otherwise leave the crash
path unhandled entirely.

Wired from AccountHandler.Initialize rather than a Configure on the worker:
AssemblyHandler.AddMethods binds Static | Public, so a Configure on an
internal type is never discovered.
2026-08-08 21:54:03 -07:00
Kamron Batman
b34c8b32ef
perf(login): move password writes off-loop too, behind one mechanism
SetPassword derives a full Argon2 hash, so the [password command, the admin
gump, account creation and the XML import each cost ~8.9 ms of frozen world.
Only the login verify had been moved.

DRY-ing the two paths surfaced a correctness trap rather than just shared
code. ApplyPasswordUpgrade guarded by comparing the stored hash, which is
right for a login rehash -- do not clobber a newer password with a rehash of
the one it superseded -- but wrong for an explicit change: two changes
dispatched before either landed would drop the second and silently keep the
older password. Ordering is now a per-account dispatch sequence claimed on
the loop, which gives "newest wins" for both callers through one mechanism.
SetPassword bumps it as well, so an inline write also supersedes an in-flight
one.

One job type serves both: PasswordJob carries an optional verify phrase and
an optional hash phrase plus an OnComplete that runs on the loop, so a login
verifies and may rehash while a password change only hashes. The class is
PasswordWorker now, since verification no longer describes what it does.

PasswordWorker.SetPassword is the single entry point and falls back to
hashing inline when the gate is off or the queue is saturated -- unlike a
login, a password change must never be silently dropped, and it is rare
enough that the loop can absorb one.

The [password confirmation moves into the callback, because off the loop it
has not happened when the call returns. Account creation stays inline: it
gates the login flow, so deferring it restructures the accept path.
2026-08-08 12:25:49 -07:00
Kamron Batman
c2055be083
docs: state when a background thread is justified, and trim the sweep
The threading model's forbidden-patterns table bans new Thread,
ConcurrentQueue, Interlocked, Semaphore and volatile in UOContent, and its
exceptions list covered only Projects/Server. Both the new password
verification worker and the existing Advanced Search fan-out already sat
outside it, so the rule as written flagged working code.

Adds a vetted-workers section that leads with proving the need rather than
listing primitives: measure on-loop time rather than wall-clock, gate on core
count because off-loading creates no CPU, and account for what stays behind.
Game logic is still never threaded -- if it is too slow for one tick it gets
chunked across ticks, and that is spelled out with an example so the
distinction is not left implicit. Then the five rules a worker must satisfy,
and the hand-off protocol in both directions, including that the continuation
must re-validate anything that could have moved and that a failure must still
post a verdict.

Both vetted workers are listed with their justification, so a future reader
can tell whether either still earns its complexity.

CLAUDE.md rule #10 gains the same "prove it first" clause. Rules #3 and #10
already covered chunking, yielding to saves, and both hand-back routes.

Also trims the implementation comments: the measurement narrative belongs in
the handoff document, not in a class header.
2026-08-08 11:37:03 -07:00
Kamron Batman
8f76a3ac31
perf(login): verify Argon2 passwords on a parked worker thread
An Argon2 verify is ~8.9 ms of frozen world per login attempt, successful or
not, so a credential flood is a full-cost stall per packet without needing
valid credentials. Measurement puts the on-loop saving at 3.5-8.9 ms: the
hand-off costs ~220 ns, and the only real residue is the loop's own work
slowing while a memory-hard KDF evicts shared L3.

One worker, not a pool. The per-login contention tax falls with concurrency
while total loop damage rises, so one hasher harms the loop least; and a
single hasher cannot cost the loop more than the inline verify under any
scheduling regime, because at worst it takes an equal share of one core.
That bound is what lets the measurement extrapolate to hardware we cannot
inspect, and a pool breaks it. It also caps live Argon2 arenas at one,
which answers memory exhaustion without a separate mechanism.

Scoped to Argon2-stored accounts. SHA and MD5 protections share a
HashAlgorithm instance whose ComputeHash is not thread safe, and they cost
microseconds anyway. Their one-time rehash into Argon2 stays on the loop
too: it costs a migrating account a single 8.9 ms login exactly as today.

The worker parks on an AutoResetEvent and never spins. The spin in
SerializationThreadWorker exists to wait on a producer mid-drain; there is
no such race here, so this is strictly cheaper at idle. It yields while the
world is in PendingSave or Saving -- PendingSave included, because the
serialization threads are already awake and spinning on an empty queue by
then.

Phrase derivation moves to AccountSecurity.DerivePhrase so verification and
rehash cannot disagree about the rule, which is the shape of the lockout
fixed in #2562. ApplyPasswordUpgrade refuses to write when the stored hash
changed while the verify ran, so a password set mid-flight is not replaced
by a rehash of the one it superseded.
2026-08-08 10:50:25 -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
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
0404251638
feat: Adds Latin1 text support (#2317)
## Summary

- Adds proper Latin1 encoding support, replacing CP1252 usage throughout the codebase
- Adds specialized, optimized string decoding methods with safe string filtering for each encoding type
- Filters invalid Unicode characters (C0/C1 control codes, non-characters) by removal rather than replacement since
the UO client renders nothing for these characters
- Fixes UTF-16 null terminator position handling to correctly advance by 2 bytes

## Changes

TextEncoding.cs

- Added SearchValues-based invalid byte/char detection for efficient filtering
- Added encoding-specific GetString methods: GetStringAscii, GetStringLatin1, GetStringUtf8, GetStringBigUni,
GetStringLittleUni
- Each method supports a safeString parameter for filtering invalid characters
- Little-endian UTF-16 uses direct memory cast for zero-copy decoding on LE systems
- Invalid characters are removed (not replaced with U+FFFD) since the client renders nothing for them

SpanReader.cs

- Added ReadLatin1() and ReadLatin1Safe() methods
- Rewrote encoding-specific read methods to use optimized TextEncoding.GetString* methods
- Fixed UTF-16 null terminator handling: position now correctly advances by byteLength (2) instead of 1

SpanWriter.cs

- Added WriteLatin1 and WriteLatin1Null methods

## Packet Updates

- Updated all packet code to use Latin1 encoding instead of CP1252
- Affected: account packets, equipment packets, menu packets, message packets, mobile packets, player packets, secure
trade packets, vendor packets, gump packets, book packets, mahjong packets

## Filtering Behavior

Invalid characters filtered in safe mode:
```
┌───────────────┬────────────────────────┐
│     Range     │      Description       │
├───────────────┼────────────────────────┤
│ 0x00-0x1F     │ C0 control codes       │
├───────────────┼────────────────────────┤
│ 0x7F          │ DEL                    │
├───────────────┼────────────────────────┤
│ 0x80-0x9F     │ C1 control codes       │
├───────────────┼────────────────────────┤
│ 0xFFFE-0xFFFF │ Unicode non-characters │
└───────────────┴────────────────────────┘
```

Note: Surrogate pairs (0xD800-0xDFFF) are not filtered because proper validation requires context checking for paired
vs unpaired surrogates. The UO client renders nothing for these anyway.

## Test Plan

- All 631 Server.Tests pass
- Verified client rendering behavior using TestUnicodeGump command (pages 1-5)
- Confirmed U+FFFD, unpaired surrogates, and non-characters all render as blank in client
- Verified Latin1 characters (0xA0-0xFF) display correctly
- Verified C1 control codes (0x80-0x9F) are filtered and don't display
2026-01-22 15:51:00 -08:00
Kamron Batman
0ce2a62a76
fix: Use vectorized search for username-password validation. (#2152)
### Summary

Optimizes account validation:

```cs
| Method              | Mean       | Error     | StdDev    |
|-------------------- |-----------:|----------:|----------:|
| ForLoopUsernameSafe | 158.536 ns | 1.6247 ns | 1.5198 ns |
| SVUsernameSafe      |   2.859 ns | 0.0581 ns | 0.0796 ns |
```
2025-04-11 22:40:06 -07:00
Kamron Batman
47e16a03fe
feat: Moves network related events to UOContent using code generation (#1945)
### Summary
- Moves `CharacterCreated` event to UOContent using code generated event _CharacterCreatedEvent_.
- Moves `TargetByResourceMacro` event to UOContent using code generated event _TargetByResourceMacro_.
- Moves `GameLogin` event to UOContent using code generated event _GameLoginEvent_.
- Moves `ServerList` event to UOContent using code generated event _ServerListEvent_.
- Streamlines ProfessionInfo


> [!IMPORTANT]
> **Developer Note**
> Custom scripts that use any of these event sinks will need to be updated to use the new code generated events.
2024-09-06 16:57:10 -07:00
Kamron Batman
21836cf62f
fix: Fixes massive capacity bug with pooled ref queue/list (#1865) 2024-07-07 16:18:56 -07:00
Kamron Batman
99e33d0463
fix: Fixes character creation issues. Adds Royal City (#1825)
### Summary

- Removes new player "Haven Only" starting city.
- Fixed New/Old haven placement.
- Removed force-profession starting location for SA+.
- Added Royal City for SA+
- Removed Occlo for Pre-AOS.
2024-06-04 20:58:30 -07:00
Kamron Batman
a4522b9d43
fix: Fixes stalled connections and infinite throttle (#1796)
> [!Warning]
> **Developer Warning**
> The `PacketThrottle` callback return value is now reversed. `true` indicates the connection is _throttled_.

### Summary
- Fixes an issue where connections get stalled forever
- Fixes an issue where the throttler is not working properly
- Removes account attack limiter
- Rewrites IP limiter
- Removes IP restrictions (they weren't used, and not practical)
- Fixes issue where IP limiter was counting before firewall was blocking.

View without whitespace:
https://github.com/modernuo/ModernUO/pull/1796/files?diff=split&w=1
2024-05-27 21:32:55 -07:00
Kamron Batman
a552cf4138
fix: Fixes various memory leaks and spells (#1786)
### Summary
- Fixes various memory leaks related to spells
- Fixes Spell Plague
- Added ability to determine if `sdi` should take effect for Spell Damage.
- Fixes animal form timer ticking non-stop while logged out.
2024-05-21 19:44:42 -07:00
Marcelo Paez Sequeira
00908d030f
fix: Moves many EventSinks out of core. (#1783) 2024-05-21 10:02:30 -07:00
Kamron Batman
d77668d77a
fix: Cleans up some string allocations from trim (#1759) 2024-05-03 22:43:11 -07:00
Kamron Batman
fffda53263
fix: Adds command help, webpage, and fixes issues with other commands (#1669)
### Summary

- Fixes `[AdvancedSearch` being accessible by players 😱 
- Adds `[GenCommands` to generate the same commands html page on https://muo.gg/commands.
- Fixes `[helpinfo` so all commands properly show up!

> [!WARNING]  
> ### Developer Warning:
> Commands must now be registered in the `Configure` bootup phase.
> If a command is not registered early enough, it may not be available to systems like [helpinfo
> that cache their information.

> [!NOTE]  
> ### Developer Note:
> Various commands related to generating content have been changed to _Developer_ and above access level.

### Screenshots
<img width="673" alt="image" src="https://github.com/modernuo/ModernUO/assets/3953314/b105b5c9-5eb4-4ace-93ff-1bfb31e7132f">

<img width="547" alt="image" src="https://github.com/modernuo/ModernUO/assets/3953314/e97487e8-47a5-4aa7-89cc-9fe3deda584d">
2024-02-10 00:19:19 -08:00
Kamron Batman
4cd668ef61
feat: Moves TcpServer to another thread. Rewrites Firewall (#1660)
## Breaking Changes
* The Firewall and IP Limiter have been rewritten. Please read the notes carefully!
* `TcpServer.Instances` moved back to `NetState.Instances` - sorry - it was stupid to move it to begin with.

> [!Note]
> Sockets that fail the IP Limiter or Firewall will be immediately and forcibly disconnected.
> This means they will be stuck at "Verifying account..." if it was a real client.

### Summary
- Removes firewall wildcard support.
- Removes `AccessRestrictions`.
- Moves Firewall/IPLimiter to the core.
- Moves `TcpServer` to its own thread.
- Removes the `SocketConnect` and `SocketDisconnect` event sinks.
- Moves `Instances` back to `NetState.Instances`.
- Fixes a long standing bug with bad handling of duplicate listener addresses.

#### Firewall
The firewall has been completely rewritten. There is now an "Admin Firewall" which saves to the config file. Secondarily, there is an internal firewall used exclusively by the TcpServer while processing sockets. The Admin firewall mirrors it's additions/deletions to the internal firewall by adding requests to a queue.

> [!IMPORTANT]  
> **Wildcard firewall entries, such as `X`, `*`, `?` are not allowed.**
> **Ranges in between IP classes or sextets are not allowed.**
> **Please make sure to use one of the following:**
> * IP Address - `192.168.1.1`
> * CIDR - `192.168.1.0/24`
> * Range - `192.168.1.1-192.168.1.100`

#### IP Limiter
The IP Limiter has been completely rewritten. The available configurations are:
```json
"ipLimiter.enable": "True",
"ipLimiter.maxConnectionsPerIP": 10,
"ipLimiter.clearConnectionAttemptsDuration": "00:00:00:10",
"ipLimiter.clearThrottledDuration": "00:00:02:00",
```

The IP Limiter is set up to prevent spamming connections from the same IP. Every time an IP connects, it is added to a connection list. After 10 attempts, the IP is added to the throttle list. To keep the system fast, the connection list is entirely wiped every 10 seconds, and the throttle list is entirely wiped every 2 minutes.
2024-01-20 14:25:12 -08:00
Kamron Batman
332e22f509
fix: Fix compiling (#1617) 2023-11-25 23:41:09 -08:00
Kamron Batman
674b8dca83
fix: Fixes character starting cities. (#1615)
### Summary

- Moves starting city info from AccountHandler to CharacterCreation
- Simplifies the logic of determining the starting cities
- Adds support for Trammel & Felucca for non-young accounts
- Fixes fall through starting city for v6+ in UOR era.
- All staff are force-sent to GA.

Closes #1408
2023-11-25 23:31:54 -08:00
Kamron Batman
26f784f45d
fix: Overhauls murder system (#1419)
## MAJOR CHANGE (API BREAKING)

Added a player murder system to facilitate reporting murders. This should make it easier to extend to create a bounty system or  other related game content. Player murders will be saved in a folder called _PlayerMurders_.

### Motivation

The motivation was two-fold, performance, and bug fixes.

First, murders are one of two systems that do a pre-world-save check on _every mobile in the game_ to decay kills and set their expiring murders. This is taxing since it freezes the world and makes world saves take longer. Every mobile has ShortTermMurders even though it is a player concept. And next, 90%+ of players are not murderers but had an ever increasing MurderElapse time that was being tracked against GameTime. These properties were also serialized unnecessarily for all mobs.

Second, when I tried to optimize/refactor the code, it was obvious that the system has bugs.

### Major API Changes
- [X] Created a player murder system and moved `ShortTermMurders`, `ShortTermElapse`, and `LongTermElapse` to the system.
- [X] Added convenience property `PlayerMobile.ShortTermMurders`.
- [X] Added convenience properties `PlayerMobile.ShortTermMurderExpiration` and `PlayerMobile.LongTermMurderExpiration`
- [X] Moved ReportMurdererGump.cs
- [X] Adds an `EventSink.PlayerDeleted` event.

### Notes
The system currently does not support NPCs. To support expiring murders on NPCs I highly recommend a different architecture for large servers (500k+ mobs including players). Specifically switching from looping through all MurderContext to a time-order link list.
2023-07-15 22:42:49 -07:00
Kamron Batman
2d00f6d27a
fix: Fixes old haven and new haven starting cities (#1097)
Adds OldHaven cities for shards that use clients older than 6.x
2022-06-29 01:23:57 -07:00
Kamron Batman
b779d7737f
fix: Adds ISpanFormattable support to Serial (#1065) 2022-06-14 18:39:46 -07:00
Kamron Batman
6e69d25e33
fix: Fixes structured logging (#1043)
- [X] Fixes various bugs in logging.
2022-06-05 01:00:22 -07:00
Kamron Batman
fc51b60cc1
fix: Adds server access with protected accounts (#915)
Adds a configuration file to specify protected accounts:
_Distribution/Configuration/server-access.json_
```json
{
  "newPasswordOnReset": false,
  "protectedAccounts": ["admin"]
}
```

Protected accounts are unbanned and reset to `AccessLevel.Owner` upon login.

The option `newPasswordOnReset` will create a new password for a protected account if it needs to be reset. The password is a random GUID and logged to the console.

_**Note:**_ If your player character was accidentally modified, simply make a new character to fix the old one.
2022-01-10 23:14:55 -08:00
Kamron Batman
3cba8c43f8
fix: Removes from literals (#893) 2021-12-24 15:56:07 -08:00
Kamron Batman
63e1b02d93
chore: Cleans up pattern checks. (#892) 2021-12-24 15:53:59 -08:00
Kamron Batman
fa5eafdaff
fix: Adds Created, LastSerialized, and BeforeSerialize for all entities. (#775)
* Adds `BeforeSerialized` for entities to handle cleanup.
* Adds `Created` and `LastSerialized` fields to all entities. While this is a big bloat, this will be necessary for identifying dangling references to other invalid entities.
* Changes formula for determining a valid reference to be _not null, not deleted, and reference's created date must be at or before the entities last serialized date_.
* Adds versioning to idx file and serializes `Created` and `LastSerialized`.
* Fixes Save Stats and also disables it by default.
2021-09-26 01:09:45 -07:00
Kamron Batman
1f499b00c6
fix(core): Fixes issues with deserialization due to bad checking of dirty tracking (#618)
Fixes a major issue where serialization was not respecting the dirty tracking feature flag.
2021-05-26 13:47:04 -07:00
Pedro Pardal
c6cbda6f52
chore(logging): Migrate some logs to ILogger (#582) 2021-04-22 08:45:49 -07:00
Pedro Pardal
3a619e0103
feat(logging): Adds support for Serilog (#573)
Example:
```cs
public class SomeClass
{
  private static ILogger _logger;
  private static Logger => _logger ??= LogFactory.GetLogger(typeof(SomeClass));
  ...
}
```
2021-04-19 23:43:54 -07:00
Kamron Batman
6c4308bce6
fix(core): Caches DateTime.NowUtc (#548)
- [X] Caches DateTime.NowUtc on the game loop (not other threads)
- [X] Replaces all locations where it makes sense
- [X] Adds Min/Max for `IComparable` (TimeSpan, DateTimes, etc)

Closes #261
2021-03-13 01:32:04 -08:00
Kamron Batman
952a3e0265
fix(core): Handles a rare NPE with LoginTimer (#508) 2021-02-11 23:47:02 -08:00
Kamron Batman
8903028b5f
fix(core): Tightens the network stack (#479)
- [X] Removes network pause/resume
- [X] Adds back packet profiler
- [X] Adds state machine to keep track and trace netstates
- [X] Changes NetState.Running back to using an interlock exchange
- [X] Adds preliminary packet throttling support


### Packet Throttling
- `[GetThrottle <packetId>` to get the delay in milliseconds for that packet
- `[SetThrottle <packetId> <delay>` to set the delay in milliseconds for that packet
The settings are saved to `Configuration/throttles.json`
2021-02-07 23:30:56 -08:00
Kamron Batman
67625ad249
fix(core): Removes disposable netstates. (#467)
- [X] Removes IDisposable from NetState
- [X] Swaps Disconnect and Dispose methods
2021-02-05 14:33:35 -08:00
Kamron Batman
77ce2e1980
fix(core): Optimizes strings / .NET 5 compatibility changes (#354)
- [X] Removes some string allocations (e.g. split)
- [X] Optimizes some collections
- [X] Converts insensitive to extension methods of built-ins.
- [X] Adds ordinal (case sensitive) string helpers
- [X] Fixes conditionals for in-game commands so they use Ordinal comparisons.
- [X] Replaces ToLower.Contains with InsensitiveContains
- [X] Adds ValueStringBuilder
- [X] Implements ValueStringBuilder in a few places where it makes sense
- [X] Removes the redundant Wrap function and replaces it with an optimized version
- [X] Fixes list conversions in Utility

Closes #351

Bumps release version
2020-12-20 23:21:55 -08:00
Kamron Batman
3551d962f7
C# 9 Cleanup (#325)
- [X] Removes EventArgs - not needed
- [X] Merges sequential checks
- [X] Removes redundant type declarations
2020-11-27 00:29:21 -08:00
Kamron Batman
525cda5413
Cleanup & Fixes for .NET 5 (#309)
- [X] Fixes several bugs
- [X] Updates more ordinal issues
- [X] Cleans up the code a bit
- [X] Turns classes static that should have been
- [X] Changes TcpServer.Instances to a HashSet

Bumps release version
2020-11-15 10:03:50 -08:00
Kamron Batman
a8d486a969
Rearranges packet code (#295) 2020-10-30 22:49:00 -07:00
Kamron Batman
0e02cf0821
Changes account packets to functional (#284)
- [X] Moved packets from objects to functions
- [X] Fixed bug where characters were showing up with their modded names in the character list

Bumps release version
2020-10-25 23:47:29 -07:00
Kamron Batman
369a27b800
Replacing Networking (#271)
- [X] Removing Kestrel & Libuv
- [X] Cleaning up NetState
- [X] Removing System.IO.Pipelines
- [X] Cleaning up packet reading
- [X] Adds a maximum of 5000 sockets (configurable) to prevent OOM
- [X] Replaces the AsyncState with a thread-safe wrapped boolean called NetworkState
- [X] Removes Parallel.ForEach (no perf gain)
- [X] Removes custom houses compression on another thread
- [X] Test high load scenarios

Bumps release version
2020-10-20 20:55:19 -07:00
Kamron Batman
a236617ad9
Some cleanup of contains keys and try get values (#246) 2020-09-13 20:20:44 -07:00
Kamron Batman
ad3775c4d7
Formats UO Content (#201) 2020-08-27 18:30:38 -07:00
Kamron Batman
390f30e706
Convert Regions/Spawns to JSON (#138) 2020-05-26 00:34:29 -07:00
Kamron Batman
8ec166bcd0
Adds assemblies config, fixes crash bugs. (#134) 2020-05-09 12:55:56 -07:00
Renamed from Projects/Scripts/Accounting/AccountHandler.cs (Browse further)