diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 92684562b..eb74c39a4 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -46,7 +46,7 @@ jobs:
- name: Install Prerequisites
run: |
brew update
- brew install icu4c libdeflate zstd argon2
+ brew install icu4c libdeflate argon2
- name: Set Library Path
run: echo "DYLD_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_LIBRARY_PATH" >> $GITHUB_ENV
- name: Build
@@ -124,12 +124,36 @@ jobs:
dnf config-manager --set-enabled crb
dnf install -y epel-release
if: ${{ matrix.epel }}
+ # Runtime packages only, deliberately. Installing the -dev packages here would add the
+ # unversioned .so symlink and mask the very thing the binding packages now probe for, so a
+ # regression in versioned-SONAME resolution would sail through CI.
- name: Install Prerequisites using dnf
- run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel
+ run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate libargon2 tzdata
if: ${{ matrix.packageManager == 'dnf' }}
+ # ICU's runtime package carries the ABI version in its name (libicu70 on jammy, libicu76 on
+ # trixie) and has no stable alias, so match it by pattern. libicu-dev was the old way to stay
+ # version-independent, but it drags in the unversioned symlink and defeats the check below.
- name: Install Prerequisites using apt
- run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata
+ run: apt-get update -y && apt-get install -y curl '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata
if: ${{ matrix.packageManager == 'apt' }}
+ # Versioned-SONAME resolution is only under test while the unversioned symlink is absent. If a
+ # base image or a package ever starts shipping it, every probe would succeed on the first try
+ # and a regression in the fallback would sail through CI, so fail loudly instead of silently
+ # testing nothing.
+ - name: Assert the unversioned .so symlinks are absent
+ run: |
+ found=""
+ for lib in libicuuc libicui18n libdeflate libargon2; do
+ hit=$(ls /usr/lib/*/"$lib".so /usr/lib64/"$lib".so 2>/dev/null || true)
+ if [ -n "$hit" ]; then
+ found="$found $hit"
+ fi
+ done
+ if [ -n "$found" ]; then
+ echo "::error::Unversioned symlinks present, so CI is no longer exercising versioned SONAME resolution:$found"
+ exit 1
+ fi
+ echo "No unversioned symlinks present; versioned SONAME resolution is under test."
- uses: actions/checkout@v7
with:
fetch-depth: 0 # avoid shallow clone so nbgv can do its work.
diff --git a/CLAUDE.md b/CLAUDE.md
index 7f24661a4..924255249 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -46,6 +46,7 @@ Apply these when writing or reviewing `.cs` files under `Projects/`.
| 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` |
diff --git a/Projects/BuildTool/BuildOptions.cs b/Projects/BuildTool/BuildOptions.cs
index 9aea5010d..9e1d56388 100644
--- a/Projects/BuildTool/BuildOptions.cs
+++ b/Projects/BuildTool/BuildOptions.cs
@@ -14,4 +14,11 @@ public sealed class BuildOptions
public string? Arch { get; set; }
public bool SkipPrereqs { get; set; }
public bool Interactive { get; set; }
+
+ ///
+ /// Report the native library prerequisites and exit. The interactive flow is the only other
+ /// path that runs these checks, so without this there is no way to verify a deployment target
+ /// from a script or a container.
+ ///
+ public bool CheckPrereqsOnly { get; set; }
}
diff --git a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs
index 4f84aa373..95c521c24 100644
--- a/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs
+++ b/Projects/BuildTool/Prerequisites/NativeLibraryChecker.cs
@@ -1,3 +1,4 @@
+using System.Runtime.InteropServices;
using BuildTool.Platform;
using BuildTool.Publishing;
@@ -31,8 +32,10 @@ public static class NativeLibraryChecker
"Linux",
[
".NET 10 Runtime — https://dotnet.microsoft.com/download/dotnet/10.0",
- "Debian/Ubuntu: sudo apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev",
- "Fedora/RHEL: sudo dnf install -y libicu libdeflate-devel zstd libargon2-devel",
+ "Debian/Ubuntu: sudo apt-get install -y libdeflate0 libargon2-1 libicuNN tzdata",
+ " (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)",
+ " (add tzdata-legacy if the shard is configured with an alias such as US/Eastern)",
+ "Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu tzdata",
"CentOS: Also requires epel-release and CRB enabled"
]
),
@@ -176,82 +179,59 @@ public static class NativeLibraryChecker
return results;
}
+ ///
+ /// Native libraries the server needs from the system on Linux, and the SONAME range to accept
+ /// for each. Rationale and per-distro package names: dev-docs/platform-prerequisites.md.
+ ///
+ private static readonly (string Name, int MinSoVersion, int MaxSoVersion)[] _linuxLibraries =
+ [
+ ("libicuuc", 60, 120),
+ ("libicui18n", 60, 120),
+ ("libdeflate", 0, 9),
+ ("libargon2", 0, 9)
+ ];
+
private static List CheckLinux(PlatformInfo platform)
- {
- return platform.PackageManager switch
- {
- PackageManager.Apt => CheckLinuxApt(),
- PackageManager.Dnf => CheckLinuxDnf(platform),
- _ => CheckLinuxGeneric(platform)
- };
- }
-
- private static List CheckLinuxApt()
{
var results = new List();
- var packages = new[] { "libicu-dev", "libdeflate-dev", "zstd", "libargon2-dev" };
var missing = new List();
- foreach (var package in packages)
+ foreach (var (name, minSoVersion, maxSoVersion) in _linuxLibraries)
{
- var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}");
- var installed = result.Success && result.StandardOutput.Contains("ii");
+ var found = CanLoad(name, minSoVersion, maxSoVersion);
- if (!installed)
+ if (!found)
{
- missing.Add(package);
+ missing.Add(name);
}
results.Add(new PrerequisiteResult
{
- Name = package,
- Passed = installed,
- Details = installed ? "Installed" : "Not installed"
+ Name = name,
+ Passed = found,
+ Details = found ? "Found" : "Not found"
});
}
- if (missing.Count > 0)
+ var hasTimeZoneData = HasTimeZoneData();
+ if (!hasTimeZoneData)
{
- results.Add(new PrerequisiteResult
- {
- Name = "Install all missing",
- Passed = false,
- IsWarning = true,
- Details = "Run the following command to install all missing dependencies:",
- InstallCommand = $"sudo apt-get install -y {string.Join(' ', missing)}"
- });
+ missing.Add("tzdata");
}
- return results;
- }
-
- private static List CheckLinuxDnf(PlatformInfo platform)
- {
- var results = new List();
- var packages = new[] { "libicu", "libdeflate-devel", "zstd", "libargon2-devel" };
- var missing = new List();
-
- foreach (var package in packages)
+ results.Add(new PrerequisiteResult
{
- var result = ProcessRunner.RunCaptured("rpm", $"-q {package}");
- var installed = result.Success;
+ Name = "tzdata",
+ Passed = hasTimeZoneData,
+ Details = hasTimeZoneData ? "Found" : "Not found — every zone except UTC will throw"
+ });
- if (!installed)
- {
- missing.Add(package);
- }
-
- results.Add(new PrerequisiteResult
- {
- Name = package,
- Passed = installed,
- Details = installed ? "Installed" : "Not installed"
- });
+ if (missing.Count == 0)
+ {
+ return results;
}
- // Check if this is CentOS (needs EPEL)
- var isCentOs = platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true;
- if (isCentOs && missing.Count > 0)
+ if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true)
{
results.Add(new PrerequisiteResult
{
@@ -263,48 +243,131 @@ public static class NativeLibraryChecker
});
}
- if (missing.Count > 0)
+ results.Add(new PrerequisiteResult
{
- results.Add(new PrerequisiteResult
- {
- Name = "Install all missing",
- Passed = false,
- IsWarning = true,
- Details = "Run the following command to install all missing dependencies:",
- InstallCommand = $"sudo dnf install -y {string.Join(' ', missing)}"
- });
- }
+ Name = "Install all missing",
+ Passed = false,
+ IsWarning = true,
+ Details = "Install the missing dependencies. The -dev/-devel packages are not required:",
+ InstallCommand = BuildInstallCommand(platform, missing)
+ });
return results;
}
- private static List CheckLinuxGeneric(PlatformInfo platform)
+ ///
+ /// tzdata is data, not a library, so no loader probe finds it. Asking the runtime rather than
+ /// stat'ing a path keeps TZDIR honoured, and the count is still accurate under
+ /// InvariantGlobalization, which this tool runs with — only display names degrade there.
+ ///
+ private static bool HasTimeZoneData()
{
- var results = new List();
-
- // Use ldconfig to check for shared libraries
- var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p");
- var ldOutput = ldResult.Success ? ldResult.StandardOutput : "";
-
- var libraries = new Dictionary
+ try
{
- ["libicu"] = "libicuuc",
- ["libdeflate"] = "libdeflate",
- ["zstd"] = "libzstd",
- ["libargon2"] = "libargon2"
- };
-
- foreach (var (name, soName) in libraries)
+ return TimeZoneInfo.GetSystemTimeZones().Count > 1;
+ }
+ catch
{
- var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase);
- results.Add(new PrerequisiteResult
- {
- Name = name,
- Passed = found,
- Details = found ? "Found" : "Not found — install using your package manager"
- });
+ return false;
+ }
+ }
+
+ ///
+ /// Asks the loader directly rather than querying a package database or scanning ldconfig's
+ /// cache, both of which answer a different question and can disagree with what dlopen will do.
+ /// Mirrors the binding packages' own probing: the unversioned name first, then libfoo.so.N
+ /// descending. Bare names go through the full loader search path, so LD_LIBRARY_PATH and
+ /// /etc/ld.so.conf.d still apply.
+ ///
+ private static bool CanLoad(string library, int minSoVersion, int maxSoVersion)
+ {
+ if (TryLoadAndFree($"{library}.so"))
+ {
+ return true;
}
- return results;
+ for (var soVersion = maxSoVersion; soVersion >= minSoVersion; soVersion--)
+ {
+ if (TryLoadAndFree($"{library}.so.{soVersion}"))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryLoadAndFree(string candidate)
+ {
+ if (!NativeLibrary.TryLoad(candidate, out var handle))
+ {
+ return false;
+ }
+
+ NativeLibrary.Free(handle);
+ return true;
+ }
+
+ private static string BuildInstallCommand(PlatformInfo platform, List missing)
+ {
+ switch (platform.PackageManager)
+ {
+ case PackageManager.Apt:
+ {
+ // Distinct because the two ICU libraries resolve to the same package, and
+ // ResolveAptIcuPackage shells out, so it is memoized rather than called per name.
+ var packages = missing.Select(
+ library => library switch
+ {
+ "libdeflate" => "libdeflate0",
+ "libargon2" => "libargon2-1",
+ "tzdata" => "tzdata",
+ _ => _aptIcuPackage ??= ResolveAptIcuPackage()
+ }
+ ).Distinct();
+
+ return $"sudo apt-get install -y {string.Join(' ', packages)}";
+ }
+ case PackageManager.Dnf:
+ {
+ var packages = missing.Select(
+ library => library switch
+ {
+ "libdeflate" => "libdeflate",
+ "libargon2" => "libargon2",
+ "tzdata" => "tzdata",
+ _ => "libicu"
+ }
+ ).Distinct();
+
+ return $"sudo dnf install -y {string.Join(' ', packages)}";
+ }
+ default:
+ return $"Install your distribution's runtime packages for: {string.Join(", ", missing)}";
+ }
+ }
+
+ private static string _aptIcuPackage;
+
+ ///
+ /// ICU's apt package carries the ABI version in its name and there is no stable alias, so ask
+ /// apt which one this release actually ships instead of printing a name that rots.
+ ///
+ private static string ResolveAptIcuPackage()
+ {
+ var result = ProcessRunner.RunCaptured("apt-cache", "search --names-only ^libicu[0-9]+$");
+ if (!result.Success)
+ {
+ return "libicu";
+ }
+
+ var best = result.StandardOutput
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .Select(line => line.Split(' ', 2)[0].Trim())
+ .Where(name => name.StartsWith("libicu", StringComparison.Ordinal))
+ .OrderBy(name => int.TryParse(name.AsSpan(6), out var version) ? version : 0)
+ .LastOrDefault();
+
+ return best ?? "libicu";
}
}
diff --git a/Projects/BuildTool/Program.cs b/Projects/BuildTool/Program.cs
index 3b36066da..915839bbf 100644
--- a/Projects/BuildTool/Program.cs
+++ b/Projects/BuildTool/Program.cs
@@ -4,6 +4,7 @@ using BuildTool.Interactive;
using BuildTool.Platform;
using BuildTool.Prerequisites;
using BuildTool.Publishing;
+using Spectre.Console;
Console.OutputEncoding = Encoding.UTF8;
@@ -36,6 +37,22 @@ options.Os ??= detectedPlatform.OsRid;
options.Arch ??= detectedPlatform.ArchRid;
var rid = $"{options.Os}-{options.Arch}";
+if (options.CheckPrereqsOnly)
+{
+ // Same renderer the guided menu uses, so the two cannot drift. Spectre drops ANSI styling by
+ // itself when stdout is not a terminal, which is the case this flag exists for, but it also
+ // falls back to an 80 column width and folds anything longer. The install hints we print are
+ // shell commands — the CentOS one is 95 characters — and a fold puts a newline in the middle of
+ // a command that someone is meant to copy. Widen the profile so they stay on one line.
+ if (Console.IsOutputRedirected)
+ {
+ AnsiConsole.Profile.Width = 200;
+ }
+
+ // Exit code is the machine-readable half: 0 when everything resolves, 1 when anything is missing.
+ return PrerequisiteChecker.CheckNativeLibraries(detectedPlatform, interactive: false) ? 0 : 1;
+}
+
// Run prerequisite checks unless skipped
if (!options.SkipPrereqs)
{
@@ -108,6 +125,12 @@ static BuildOptions ParseArguments(string[] args)
hasNamedArgs = true;
break;
}
+ case "--check-prereqs":
+ {
+ options.CheckPrereqsOnly = true;
+ hasNamedArgs = true;
+ break;
+ }
case "--interactive":
{
options.Interactive = true;
diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj
index b022c584c..8ad6a610b 100644
--- a/Projects/Server/Server.csproj
+++ b/Projects/Server/Server.csproj
@@ -36,7 +36,7 @@
-
+
diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj
index 4f230902d..bf288e629 100644
--- a/Projects/UOContent/UOContent.csproj
+++ b/Projects/UOContent/UOContent.csproj
@@ -40,12 +40,12 @@
false
-
+
-
+
diff --git a/README.md b/README.md
index b61204743..2321fe8e3 100644
--- a/README.md
+++ b/README.md
@@ -87,18 +87,31 @@ dnf install -y dnf-plugins-core
dnf config-manager --set-enabled crb
dnf install -y epel-release
# Prerequisites
-dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel
+dnf install -y findutils libicu libdeflate libargon2 tzdata
```
### Ubuntu, Debian, etc
```shell
apt-get update -y
-apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev
+# The ICU runtime package carries the ABI version in its name (libicu74, libicu76, …) and has no
+# stable alias, so match it by pattern rather than pinning a release-specific name.
+apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata
```
+Only the runtime libraries are needed — the `-dev`/`-devel` packages are not. Run
+`./build-tool --check-prereqs` to check the current machine and print the exact packages your
+release needs.
+
+`zstd` is not listed because ZstdNet bundles `libzstd` for every platform, and `liburing` is not
+listed because IORingGroup issues `io_uring` syscalls directly.
+
+If the shard's configured time zone is a legacy alias such as `US/Eastern`, Debian 12 and Ubuntu
+24.04 also need `tzdata-legacy`. See [Platform Prerequisites](dev-docs/platform-prerequisites.md)
+for what each dependency is for and what breaks without it.
+
## OSX Requirements
```shell
-brew install icu4c libdeflate zstd argon2
+brew install icu4c libdeflate argon2
```
## Running the Server
diff --git a/dev-docs/platform-prerequisites.md b/dev-docs/platform-prerequisites.md
new file mode 100644
index 000000000..3e76d3b47
--- /dev/null
+++ b/dev-docs/platform-prerequisites.md
@@ -0,0 +1,159 @@
+# Platform Prerequisites
+
+OS-level dependencies ModernUO needs at runtime, why each one is required, and what breaks without
+it. This page is about software packages, not hardware sizing.
+
+Run `./build-tool --check-prereqs` from the repository root to check the current machine. It prints
+the exact install command for the detected distribution.
+
+## What is required
+
+| Dependency | Platform | Why |
+|---|---|---|
+| .NET 10 Runtime | all | — |
+| ICU (`libicuuc`, `libicui18n`) | Linux, macOS | The runtime refuses to start without it; see below |
+| tzdata | Linux | Time zone lookups; see below |
+| `libdeflate` | all | `LibDeflate.Bindings` |
+| `libargon2` | all | `Argon2.Bindings` (password hashing) |
+| VC++ Redistributable v14 | Windows | Native bindings |
+
+Not required, despite appearances:
+
+- **zstd** — `ZstdNet` bundles `libzstd` for every RID.
+- **liburing** — `IORingGroup` issues `io_uring` syscalls directly. It imports only `libc`,
+ `libSystem.dylib`, `kernel32.dll`, `kernelbase.dll` and `ws2_32.dll`.
+- **`-dev` / `-devel` packages** — see "Runtime packages only" below.
+
+## Install
+
+```sh
+# Debian / Ubuntu (ICU has no stable package alias, so match it by pattern)
+sudo apt-get install -y '^libicu[0-9]+$' libdeflate0 libargon2-1 tzdata
+
+# Fedora / RHEL
+sudo dnf install -y libdeflate libargon2 libicu tzdata
+
+# Alpine
+apk add --no-cache libdeflate argon2-libs icu-libs tzdata
+
+# macOS
+brew install icu4c libdeflate argon2
+```
+
+CentOS additionally needs EPEL and CRB:
+
+```sh
+sudo dnf install -y epel-release epel-next-release && sudo dnf config-manager --set-enabled crb
+```
+
+## Runtime packages only
+
+Only the runtime packages are needed. The `-dev`/`-devel` packages are **not** required.
+
+They used to be, because .NET's `DllImport` probing looks for the unversioned `libfoo.so`, and on
+Linux that bare symlink ships only in the development package. The runtime package ships the
+versioned SONAME (`libdeflate.so.0`, `libargon2.so.1`). The binding packages now probe the versioned
+names as well, so the runtime package is sufficient.
+
+Anything still documenting `libicu-dev` or `libdeflate-dev` as a requirement is out of date.
+
+## ICU
+
+`Directory.Build.props` sets `InvariantGlobalization=false`, so ICU is mandatory. Without it the
+runtime does **not** throw — it `FailFast`s:
+
+```
+Couldn't find a valid ICU package installed on the system. Please install libicu (or icu-libs)
+using your package manager and try again.
+```
+
+That is `SIGABRT` (exit 134) and it cannot be caught. Note the process **starts cleanly and aborts
+later**, at whatever line first touches a culture, so the crash rarely points at the cause.
+
+### Why invariant mode is not an option
+
+`InvariantGlobalization=true` would remove the ICU dependency, but it changes behaviour in ways that
+corrupt data silently. Measured on .NET 10 with the repository's settings:
+
+| Behaviour | With ICU | Invariant mode |
+|---|---|---|
+| `new CultureInfo("de-DE")` | real culture | succeeds, returns invariant data |
+| de-DE decimal separator | `,` | `.` |
+| `1234.5` as de-DE | `1.234,5` | `1,234.5` |
+| `string.Compare("a", "B", InvariantCulture)` | `-1` (linguistic) | `31` (ordinal) |
+| sort `[b, A, a, B]` | `a, A, b, B` | `A, B, a, b` |
+| `FindSystemTimeZoneById("Eastern Standard Time")` on Linux | resolves | `TimeZoneNotFoundException` |
+| UTF-8 round-trip of non-ASCII | unaffected | unaffected |
+
+The dangerous row is the first. Because `Directory.Build.props` also sets
+`PredefinedCulturesOnly=false`, constructing a culture in invariant mode **succeeds** instead of
+throwing `CultureNotFoundException`, and hands back an object populated with invariant data. Number
+parsing and formatting then produce wrong values with no error, and culture-sensitive sort order
+silently becomes ordinal.
+
+Encoding is not affected — UTF-8 round-trips correctly in both modes.
+
+### Version floor
+
+The runtime accepts `libicuuc.so.60` and above (`MinICUVersion` in `pal_icushim.c`). The prerequisite
+checker enforces the same floor, so a host carrying only an older ICU is reported missing rather than
+passing and then aborting at startup. RHEL/CentOS 7 ships ICU 50 and is affected.
+
+ICU tracks its own release train, so the SONAME digit varies widely by distribution — `.so.74` on
+Ubuntu 24.04, `.so.76` on Alpine, `.so.77` on Fedora, `.so.78` on openSUSE. There is no stable
+package alias on Debian and Ubuntu, which is why the checker resolves the name via `apt-cache`
+instead of hardcoding one.
+
+Only `libicuuc` and `libicui18n` are used; those are the two names
+`libSystem.Globalization.Native.so` loads. `libicudata` arrives as a dependency of `libicuuc`, and
+`libicuio`/`libicutu`/`libicutest` are never referenced. Every distribution ships all of them in a
+single package, so installing ICU at all satisfies both.
+
+## tzdata
+
+The event scheduler resolves configured zone IDs through `TimeZoneInfo`, which reads
+`/usr/share/zoneinfo` on Linux. This is separate from ICU: it is data, not a library, so no loader
+probe finds it, and slim container images routinely omit it.
+
+Without tzdata every lookup except `UTC` throws:
+
+```
+TimeZoneNotFoundException: The time zone ID 'America/New_York' was not found on the local computer.
+```
+
+`TimeZoneInfo.GetSystemTimeZones()` returns 1 entry instead of ~419, and `TimeZoneInfo.Local` falls
+back to UTC.
+
+### There is no per-zone subset
+
+Distributions do not package individual zones — it is one `tzdata` package, about 2 MB installed for
+the full set. Subsetting is not worth pursuing.
+
+The one split that does exist is **`tzdata-legacy`** on Debian 12 and Ubuntu 24.04, which carries the
+deprecated aliases. With plain `tzdata` alone:
+
+| Zone ID | `tzdata` | `tzdata-legacy` |
+|---|---|---|
+| `America/New_York` | present | — |
+| `Europe/Kyiv` | present | — |
+| `EST5EDT` | present | — |
+| `US/Eastern` | **missing** | present |
+| `Asia/Calcutta` | **missing** | present |
+
+So a shard configured with a legacy alias such as `US/Eastern` throws on a current Debian or Ubuntu
+even though tzdata is installed. Either install `tzdata-legacy` or switch the configured value to the
+canonical ID (`America/New_York`, `Asia/Kolkata`).
+
+`TZDIR` is honoured if the data lives somewhere non-standard.
+
+## How the check works
+
+`--check-prereqs` asks the loader directly — `NativeLibrary.TryLoad` on the unversioned name, then
+`libfoo.so.N` descending through the accepted range.
+
+It deliberately does not consult a package database or `ldconfig -p`. Both answer a different
+question than "will `dlopen` succeed":
+
+- Package queries need a hardcoded name, which does not exist for ICU.
+- `ldconfig`'s cache can be stale, omits `LD_LIBRARY_PATH`, and carries no version information to
+ enforce the ICU floor against. On musl it exits successfully while producing nothing usable.