ModernUO/CLAUDE.md
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

11 KiB

ModernUO

.NET 10 Ultima Online server emulator. Single-threaded game loop. All game logic runs on one thread.

  • Server engine: Projects/Server/ — do NOT modify without explicit request
  • Game content: Projects/UOContent/ — primary editing target
  • Build: dotnet build from repo root

Code Audit Rules

Apply these when writing or reviewing .cs files under Projects/.

  1. LINQ — Tier 1 (zero-cost patterns) free on hot paths; Tier 2 (low overhead) OK on warm paths; Tier 3 (allocating) forbidden on hot paths → dev-docs/code-standards.md
  2. No Console.WriteLine — use LogFactory.GetLogger(typeof(MyClass))logger.Information(...) (requires using Server.Logging;)
  3. 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>()
  5. Clean up refs in OnDelete()/OnAfterDelete() — null out Item/Mobile references
  6. Cancel timers in OnDelete()/OnAfterDelete() — call _token.Cancel() or _timer?.Stop()
  7. STArrayPool<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
  9. Serialization — class must be partial, constructor needs [Constructible], TimerExecutionToken must NOT have [SerializableField]. New classes: use [SerializationGenerator(version)] (omit encoded). When bumping versions, add MigrateFrom(VXContent) (X = previous version). Never modify Deserialize(reader, version) for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass false if old code used reader.ReadInt(), bump version +1, and keep old logic as private void Deserialize(IGenericReader reader, int version)dev-docs/runuo-migration-docs/02-serialization.md
  10. No Task.Run/new Thread() for game logic (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). When such work must feed game logic: run the heavy/I/O part off-loop and ConfigureAwait(false) its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back explicitly — publish an immutable snapshot swapped via a volatile reference (the loop reads it lock-free), or marshal the apply step with Core.LoopContext.Post(() => …). Never touch game state off-thread; never let the scheduler decide where the heavy work runs → dev-docs/threading-model.md
  11. Never assume era — if code uses Core.AOS/Core.SE/etc., ask which expansion to target
  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
  14. PropertyList string literals must be holes$"{"Map"}\t{value}" not $"Map\t{value}". The handler treats bare text as delimiters, {} holes as arguments. Only \t should be a bare literal → dev-docs/property-lists.md
  15. Braces required on all control flowif, else, for, foreach, while, do, switch must always have braces, even for single-line bodies → 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
  18. Interpolation anti-patterns on handler-aware APIsSend*/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

Dev-Docs Reference

Topic File
Code standards & LINQ tiers dev-docs/code-standards.md
Serialization system dev-docs/serialization.md
Content patterns (Items, Mobiles, Creatures) dev-docs/content-patterns.md
Era & expansion handling dev-docs/era-expansion.md
Timer system dev-docs/timers.md
Event scheduler (wall-clock/calendar) dev-docs/event-scheduler.md
Object property lists (tooltips) dev-docs/property-lists.md
Gump (UI dialog) system dev-docs/gump-system.md
Commands & targeting dev-docs/commands-targeting.md
Event system dev-docs/events.md
Threading model dev-docs/threading-model.md
Server lifecycle & bootstrap phases (Configure/ConfigurePrompts/Initialize) dev-docs/server-lifecycle.md
Platform prerequisites (ICU, tzdata, native libs per distro) dev-docs/platform-prerequisites.md
Configuration system dev-docs/configuration.md
Networking & packets dev-docs/networking-packets.md
IP bans, blocklists & allowlists (incl. unblocking a player) dev-docs/ip-bans-and-allowlists.md
Region system dev-docs/regions.md
String handling & ValueStringBuilder dev-docs/string-handling.md
RunUO migration (overview) dev-docs/runuo-migration-docs/00-overview.md
RunUO migration (all docs) dev-docs/runuo-migration-docs/

Claude Skills (Opt-In)

Detailed Claude Code skills live in dev-docs/claude-skills/. They are not auto-loaded — they must be copied to .claude/skills/ to activate.

When to offer: If the user is building complex content (new items, creatures, spells, gumps, quests, packets, serialization work, etc.), ask:

I have detailed Claude Code skills for this kind of work. Want me to enable them? I'll copy the relevant files from dev-docs/claude-skills/ to .claude/skills/.

Then copy only the relevant skill files based on the task:

Task Skills to enable
New Item or Mobile modernuo-content-patterns, modernuo-serialization, modernuo-property-lists
Creature / spawn modernuo-content-patterns, modernuo-serialization, modernuo-timers
Spell or ability modernuo-content-patterns, modernuo-serialization, modernuo-timers, modernuo-era-expansion
Gump / UI dialog modernuo-gump-system, modernuo-commands-targeting
Quest or event system modernuo-events, modernuo-content-patterns, modernuo-configuration
Scheduled / seasonal / holiday events modernuo-event-scheduler, modernuo-timers
Custom regions / dynamic areas modernuo-regions, modernuo-content-patterns
Packet / networking modernuo-networking, modernuo-threading
Commands modernuo-commands-targeting
Timer work modernuo-timers, modernuo-serialization
Config system modernuo-configuration
Era-conditional code modernuo-era-expansion
String building / formatting modernuo-string-handling
Code review / audit modernuo-code-audit
Any .cs file edit modernuo-code-audit (always offer for code changes)
RunUO Migration
Migrate any RunUO script migrate-from-runuo/migrate-foundation (always), plus system-specific skills below
Migrate Item/Mobile/Creature migrate-from-runuo/migrate-foundation, migrate-from-runuo/migrate-serialization, migrate-from-runuo/migrate-items-mobiles
Migrate serialization migrate-from-runuo/migrate-serialization
Migrate timers migrate-from-runuo/migrate-timers
Migrate gumps migrate-from-runuo/migrate-gumps
Migrate packets migrate-from-runuo/migrate-packets
Migrate property lists migrate-from-runuo/migrate-property-lists
Migrate events/commands migrate-from-runuo/migrate-commands-events
Migrate persistence (WorldSave) migrate-from-runuo/migrate-persistence
Migrate multi-file system migrate-from-runuo/migrate-systems

To enable a skill: cp dev-docs/claude-skills/<name>.md .claude/skills/

Migration skills reference the deep docs in dev-docs/runuo-migration-docs/ and point to existing ModernUO skills for best practices.

The modernuo-code-audit skill auto-triggers on .cs file edits and flags convention violations (warnings only, asks before fixing).