diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index 92684562b..359efdcfc 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,11 +124,14 @@ 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
if: ${{ matrix.packageManager == 'dnf' }}
- 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-dev libdeflate0 libargon2-1 tzdata
if: ${{ matrix.packageManager == 'apt' }}
- uses: actions/checkout@v7
with:
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..8050f6533 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,9 @@ 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",
+ " (libicuNN varies by release, e.g. libicu76 — run build-tool --check-prereqs there for the exact name)",
+ "Fedora/RHEL: sudo dnf install -y libdeflate libargon2 libicu",
"CentOS: Also requires epel-release and CRB enabled"
]
),
@@ -176,82 +178,70 @@ public static class NativeLibraryChecker
return results;
}
+ ///
+ /// Libraries the server needs from the system on Linux.
+ ///
+ /// zstd is absent because ZstdNet bundles libzstd for every RID. liburing is absent because
+ /// IORingGroup issues io_uring syscalls directly and imports only libc, libSystem.dylib,
+ /// kernel32.dll, kernelbase.dll and ws2_32.dll.
+ ///
+ /// ICU is here because the server does not set InvariantGlobalization and its runtimeconfig
+ /// sets System.Globalization.PredefinedCulturesOnly to false, so it genuinely needs ICU.
+ ///
+ /// MaxSoVersion bounds the dlopen fallback used when ldconfig cannot answer. It is per library
+ /// because the SONAME digit is: libdeflate is .so.0 and libargon2 is .so.1 on the same machine,
+ /// while ICU tracks its own release train and was .so.74 on Ubuntu 24.04, .so.76 on Alpine and
+ /// .so.77 on Fedora. A single small bound silently reports ICU missing when it is installed.
+ ///
+ private static readonly (string Name, int MaxSoVersion)[] _linuxLibraries =
+ [
+ ("libicuuc", 99),
+ ("libdeflate", 9),
+ ("libargon2", 9)
+ ];
+
private static List CheckLinux(PlatformInfo platform)
{
- return platform.PackageManager switch
- {
- PackageManager.Apt => CheckLinuxApt(),
- PackageManager.Dnf => CheckLinuxDnf(platform),
- _ => CheckLinuxGeneric(platform)
- };
- }
+ // Ask whether the loader can find each library rather than whether a named package is
+ // installed. Package names were why the -dev packages were mandated, and no hardcoded name
+ // works for ICU anyway: its apt package is release-specific (libicu72, libicu74, ...).
+ // ldconfig -p is the loader's own cache, so matching on the "libfoo.so" prefix covers
+ // libdeflate.so.0, libargon2.so.1 and libicuuc.so.76 alike.
+ //
+ // It is only ever a fast *positive* signal. musl's ldconfig exits 0 while producing no
+ // usable cache, so trusting a negative from it reports every library missing on Alpine even
+ // when all of them are installed. A cache can also be stale or omit LD_LIBRARY_PATH.
+ // Anything it does not vouch for gets dlopen'd for real before being called missing.
+ var ldResult = ProcessRunner.RunCaptured("ldconfig", "-p");
+ var cache = ldResult.Success ? ldResult.StandardOutput : null;
- 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, maxSoVersion) in _linuxLibraries)
{
- var result = ProcessRunner.RunCaptured("dpkg", $"-l {package}");
- var installed = result.Success && result.StandardOutput.Contains("ii");
+ var found = cache?.Contains($"{name}.so", StringComparison.Ordinal) == true ||
+ CanLoad(name, 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)
+ if (missing.Count == 0)
{
- 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)}"
- });
+ return results;
}
- 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)
- {
- 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)
+ if (platform.DistroId?.Equals("centos", StringComparison.OrdinalIgnoreCase) == true)
{
results.Add(new PrerequisiteResult
{
@@ -263,48 +253,106 @@ 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 runtime libraries. The -dev/-devel packages are not required:",
+ InstallCommand = BuildInstallCommand(platform, missing)
+ });
return results;
}
- private static List CheckLinuxGeneric(PlatformInfo platform)
+ ///
+ /// Asks the loader directly, for when ldconfig cannot answer. 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 maxSoVersion)
{
- 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
+ if (TryLoadAndFree($"{library}.so"))
{
- ["libicu"] = "libicuuc",
- ["libdeflate"] = "libdeflate",
- ["zstd"] = "libzstd",
- ["libargon2"] = "libargon2"
- };
-
- foreach (var (name, soName) in libraries)
- {
- var found = ldOutput.Contains(soName, StringComparison.OrdinalIgnoreCase);
- results.Add(new PrerequisiteResult
- {
- Name = name,
- Passed = found,
- Details = found ? "Found" : "Not found — install using your package manager"
- });
+ return true;
}
- return results;
+ for (var soVersion = maxSoVersion; soVersion >= 0; 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:
+ {
+ var packages = missing.Select(
+ library => library switch
+ {
+ "libdeflate" => "libdeflate0",
+ "libargon2" => "libargon2-1",
+ _ => ResolveAptIcuPackage()
+ }
+ );
+
+ return $"sudo apt-get install -y {string.Join(' ', packages)}";
+ }
+ case PackageManager.Dnf:
+ {
+ var packages = missing.Select(
+ library => library switch
+ {
+ "libdeflate" => "libdeflate",
+ "libargon2" => "libargon2",
+ _ => "libicu"
+ }
+ );
+
+ return $"sudo dnf install -y {string.Join(' ', packages)}";
+ }
+ default:
+ return $"Install your distribution's runtime packages for: {string.Join(", ", missing)}";
+ }
+ }
+
+ ///
+ /// 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..dd2b16479 100644
--- a/Projects/BuildTool/Program.cs
+++ b/Projects/BuildTool/Program.cs
@@ -36,6 +36,29 @@ options.Os ??= detectedPlatform.OsRid;
options.Arch ??= detectedPlatform.ArchRid;
var rid = $"{options.Os}-{options.Arch}";
+if (options.CheckPrereqsOnly)
+{
+ var allPassed = true;
+
+ foreach (var result in NativeLibraryChecker.Check(detectedPlatform))
+ {
+ if (!result.IsWarning)
+ {
+ Console.WriteLine($"{result.Name,-16} {(result.Passed ? "OK" : "MISSING"),-8} {result.Details}");
+ allPassed &= result.Passed;
+ continue;
+ }
+
+ Console.WriteLine(result.Details);
+ if (result.InstallCommand is not null)
+ {
+ Console.WriteLine($" {result.InstallCommand}");
+ }
+ }
+
+ return allPassed ? 0 : 1;
+}
+
// Run prerequisite checks unless skipped
if (!options.SkipPrereqs)
{
@@ -108,6 +131,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..1b76ce804 100644
--- a/README.md
+++ b/README.md
@@ -87,18 +87,25 @@ 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
```
### Ubuntu, Debian, etc
```shell
apt-get update -y
-apt-get install -y libicu-dev libdeflate-dev zstd libargon2-dev
+apt-get install -y libicu-dev libdeflate0 libargon2-1
```
+Only the runtime libraries are needed — the `-dev`/`-devel` packages are not. ICU is the exception
+on Debian and Ubuntu, where the runtime package carries the ABI version in its name (`libicu74`,
+`libicu76`, …) and there is no stable alias, so `libicu-dev` is the version-independent way to pull
+it in. Run `./build-tool --check-prereqs` to print the exact packages your release needs.
+
+`zstd` is no longer listed because ZstdNet bundles `libzstd` for every platform.
+
## OSX Requirements
```shell
-brew install icu4c libdeflate zstd argon2
+brew install icu4c libdeflate argon2
```
## Running the Server